Files

260 lines
12 KiB
JavaScript

// 계약심사 입력은 작업본에 보관하고, 원래 내역은 사본을 열 때까지 유지한다.
const EstReview = (() => {
const fields = ["GN", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC"];
const special = new Set([51, 52, 54, 55, 57, 58, 59, 62, 67, 68]);
const editable = (row) => !EstMath.num(row.Level) && !special.has(EstMath.num(row.LinkIndex));
const fixed = (row) => [1, 3].includes(EstMath.num(row.CalcIndex));
const allowed = (row) => ["GN", ...(fixed(row) ? EstMath.components : EstMath.units)];
const rowKey = (row, index) => `row:${EstBusiness.rowKey(row, index)}`;
function validate(project) {
const review = project.contractReview;
if (review == null) return;
if (
typeof review !== "object" ||
Array.isArray(review) ||
!review.edits ||
typeof review.edits !== "object" ||
Array.isArray(review.edits)
)
throw Error("계약심사 자료 형식 오류");
if (Object.keys(review.edits).length > 100000) throw Error("계약심사 항목 수 초과");
for (const [key, edit] of Object.entries(review.edits)) {
if (!key || key.length > 1000 || !edit || typeof edit !== "object" || Array.isArray(edit))
throw Error("계약심사 입력 형식 오류");
for (const [field, value] of Object.entries(edit)) {
if (field === "Reason") {
if (typeof value !== "string" || value.length > 10000)
throw Error("계약심사 사유 형식 오류");
continue;
}
if (
!fields.includes(field) ||
typeof value !== "number" ||
!Number.isFinite(value) ||
Math.abs(value) > Number.MAX_SAFE_INTEGER
)
throw Error(`계약심사 입력 오류: ${field}`);
}
}
}
function calculate(project) {
validate(project);
const edits = project.contractReview?.edits || {},
errors = [],
seen = new Set();
const working = EstNative.empty();
working.options = { ...project.options, SourceRefresh: false, TotalFix: false };
working.tables.EstMainDat = structuredClone(project.tables.EstMainDat);
working.tables.Wonga = structuredClone(project.tables.Wonga);
const baseline = EstNative.calculate(working);
const counts = new Map();
working.tables.EstMainDat.forEach((row, index) => {
if (!editable(row)) return;
const key = rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
working.tables.EstMainDat.forEach((row, index) => {
if (!editable(row)) return;
const key = rowKey(row, index);
if (counts.get(key) > 1) errors.push(`심사 내역 식별자 중복: ${key}`);
seen.add(key);
if (
fixed(row) &&
EstMath.even(EstMath.num(row.TC) * 10000) !== EstMath.even(EstMath.sum(row) * 10000)
)
errors.push(
`${row.Name || key}: 고정 금액과 비목 합계가 달라 심사 합계를 계산할 수 없습니다.`,
);
const edit = edits[key];
if (!edit || counts.get(key) > 1) return;
for (const field of Object.keys(edit)) {
if (field === "Reason") continue;
if (!allowed(row).includes(field))
errors.push(`${row.Name || key}: 심사할 수 없는 항목 ${field}`);
else row[field] = edit[field];
}
if (fixed(row) && EstMath.components.some((field) => Object.hasOwn(edit, field)))
row.TC = EstMath.sum(row);
});
for (const key of Object.keys(edits))
if (!seen.has(key)) errors.push(`심사 대상 내역 없음: ${key}`);
const reviewed = EstNative.calculate(working);
errors.push(...baseline.calculation.errors, ...reviewed.calculation.errors);
const total = (p) =>
p.tables.Wonga.length
? EstMath.num(p.calculation.total)
: EstMath.num(p.calculation.direct.TC);
return {
baseline,
reviewed,
before: total(baseline),
after: total(reviewed),
errors: [...new Set(errors)],
};
}
function copy(project) {
const output = calculate(project);
if (output.errors.length) throw Error(output.errors.join(" / "));
const next = structuredClone(project);
delete next.id;
delete next.contractReview;
next.name = `${project.name} · 심사 내역`;
next.tables.EstMainDat = output.reviewed.tables.EstMainDat;
next.tables.Wonga = output.reviewed.tables.Wonga;
next.options.SourceRefresh = false;
next.calculation = output.reviewed.calculation;
return next;
}
function render(state) {
const { esc, number, byId } = EstApp,
project = state.project,
output = calculate(project);
const edits = project.contractReview?.edits || {};
const rows = output.baseline.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
({ row }) => editable(row) && (row.Name || row.Code),
);
byId("work-content").innerHTML =
`<div class="business-tools"><b>계약심사</b><button data-review-copy>심사 내역 사본 열기</button><button data-review-clear>심사 입력 초기화</button><label>보기 <select id="review-filter"><option value="all" ${state.reviewFilter === "all" || !state.reviewFilter ? "selected" : ""}>전체 항목</option><option value="changed" ${state.reviewFilter === "changed" ? "selected" : ""}>변경 항목만</option><option value="has-reason" ${state.reviewFilter === "has-reason" ? "selected" : ""}>사유 작성 항목</option><option value="no-reason" ${state.reviewFilter === "no-reason" ? "selected" : ""}>사유 미작성 변경항목</option></select></label><button data-review-batch-reason>선택/표시 행 사유 일괄적용</button><span>기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}</span></div><p>심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.</p><label>주요심사 내용<textarea data-review-summary rows="4" maxlength="100000" style="width:100%">${esc(project.documents?.["173"] || "")}</textarea></label><p class="business-note">${esc(output.errors.join(" / "))}</p><table class="sheet"><thead><tr>${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => `<th>${value}</th>`).join("")}</tr></thead><tbody>${rows
.filter(({ row, index }) => {
const key = rowKey(row, index),
input = edits[key] || {},
changed = output.reviewed.tables.EstMainDat[index],
isChanged =
Object.hasOwn(edits, key) ||
EstMath.num(changed.TC) !== EstMath.num(row.TC) ||
EstMath.num(changed.GN) !== EstMath.num(row.GN),
hasReason = Boolean(input.Reason && input.Reason.trim());
const filter = state.reviewFilter || "all";
if (filter === "changed") return isChanged;
if (filter === "has-reason") return hasReason;
if (filter === "no-reason") return isChanged && !hasReason;
return true;
})
.map(({ row, index }) => {
const key = rowKey(row, index),
input = edits[key] || {},
changed = output.reviewed.tables.EstMainDat[index],
isSelected = state.reviewSelection?.has(key);
const cell = (field) =>
`<input data-review="${field}" data-key="${esc(key)}" type="number" step="any" value="${esc(input[field] ?? EstMath.num(row[field]))}" aria-label="심사 ${esc(field)}">`;
return `<tr class="${isSelected ? "selected" : ""}"><td><input type="checkbox" data-review-select="${esc(key)}" ${isSelected ? "checked" : ""} aria-label="선택"></td><td>${esc(row.Code)}</td><td>${esc(row.Name)}</td><td>${number(row.GN)}</td><td>${cell("GN")}</td>${allowed(
row,
)
.slice(1)
.map((field) => `<td>${cell(field)}</td>`)
.join(
"",
)}<td>${number(row.TC)}</td><td>${number(changed.TC)}</td><td>${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}</td><td><input data-review="Reason" data-key="${esc(key)}" value="${esc(input.Reason || "")}" maxlength="10000" aria-label="심사 사유"></td><td><button data-review-reset="${esc(key)}" ${Object.hasOwn(edits, key) ? "" : "disabled"}>행 초기화</button></td></tr>`;
})
.join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
const node = event.target;
if (node.id === "review-filter") {
EstApp.state.reviewFilter = node.value;
EstApp.render();
return;
}
if (node.dataset.reviewSelect !== undefined) {
const key = node.dataset.reviewSelect;
const sel = (EstApp.state.reviewSelection ||= new Set());
if (node.checked) sel.add(key);
else sel.delete(key);
EstApp.render();
return;
}
if (node.dataset.reviewSummary !== undefined) {
EstApp.change(() => {
const project = EstApp.state.project;
(project.documents ||= {})["173"] = node.value;
if (String(project.printSettings?.form) === "173")
project.printSettings.notes = node.value;
}, false);
return;
}
if (!node.dataset.review) return;
const field = node.dataset.review,
value = field === "Reason" ? node.value : Number(node.value),
key = node.dataset.key;
const matches = EstApp.state.project.tables.EstMainDat.filter(
(row, index) => editable(row) && rowKey(row, index) === key,
);
if (matches.length !== 1) {
EstApp.state.notice = "심사 내역 식별자가 없거나 중복됩니다.";
EstApp.render();
return;
}
if (
field === "Reason"
? value.length > 10000
: !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER
) {
EstApp.state.notice = "유효한 심사 수치를 입력하세요.";
EstApp.render();
return;
}
EstApp.change(() => {
const review = (EstApp.state.project.contractReview ||= { edits: {} });
(review.edits[key] ||= {})[field] = value;
}, false);
});
document.addEventListener("click", (event) => {
const project = EstApp.state.project;
try {
const reset = event.target.closest("[data-review-reset]");
if (reset && project.contractReview?.edits) {
const key = reset.dataset.reviewReset;
if (Object.hasOwn(project.contractReview.edits, key))
EstApp.change(() => {
delete project.contractReview.edits[key];
if (!Object.keys(project.contractReview.edits).length) delete project.contractReview;
}, false);
} else if (event.target.closest("[data-review-copy]")) {
const next = copy(project);
if (confirm("현재 작업 대신 심사 내역 사본을 여시겠습니까?")) {
EstApp.setProject(next);
EstApp.navigate("EstMainDat");
}
} else if (
event.target.closest("[data-review-clear]") &&
confirm("심사 입력을 초기화하시겠습니까?")
) {
EstApp.change(() => delete project.contractReview, false);
EstApp.state.reviewSelection?.clear();
} else if (event.target.closest("[data-review-batch-reason]")) {
const reason = prompt("적용할 심사 사유를 입력하세요:");
if (reason != null && reason.trim()) {
const val = reason.trim().slice(0, 10000);
const sel = EstApp.state.reviewSelection;
const targetKeys =
sel && sel.size > 0
? [...sel]
: [
...document.querySelectorAll(
'#work-content input[data-review="Reason"][data-key]',
),
].map((node) => node.dataset.key);
if (!targetKeys.length) {
EstApp.state.notice = "사유를 적용할 대상 행이 없습니다.";
EstApp.render();
return;
}
EstApp.change(() => {
const review = (EstApp.state.project.contractReview ||= { edits: {} });
for (const k of targetKeys) {
(review.edits[k] ||= {}).Reason = val;
}
}, false);
}
}
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
}
return { validate, calculate, copy, rowKey, editable, allowed, render, install };
})();