// 계약심사 입력은 작업본에 보관하고, 원래 내역은 사본을 열 때까지 유지한다. 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; // 원본 심사 자료처럼 설계(심사 전) 원가 금액을 Cost_ 에 남긴다(심사평가결과 설계금액). next.tables.Wonga = output.reviewed.tables.Wonga.map((row, i) => ({ ...row, Cost_: EstMath.num(output.baseline.tables.Wonga?.[i]?.Cost), })); 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 = `
계약심사기존 ${number(output.before)} · 심사 ${number(output.after)} · 차액 ${number(output.after - output.before)}

심사 수량과 비목별 단가를 직접 입력합니다. 고정금액 행은 비목별 금액을 입력합니다. 원래 내역은 유지되며 심사 사본에서 변경됩니다.

${esc(output.errors.join(" / "))}

${["선택", "코드", "품명", "기존 수량", "심사 수량", "노무", "재료", "경비", "실적", "기존 금액", "심사 금액", "차액", "심사 사유", "작업"].map((value) => ``).join("")}${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) => ``; return `${allowed( row, ) .slice(1) .map((field) => ``) .join( "", )}`; }) .join("")}
${value}
${esc(row.Code)}${esc(row.Name)}${number(row.GN)}${cell("GN")}${cell(field)}${number(row.TC)}${number(changed.TC)}${number(EstMath.num(changed.TC) - EstMath.num(row.TC))}
`; } 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.reviewResult !== undefined) { EstApp.change( () => ((EstApp.state.project.documents ||= {})["173-result"] = node.value), false, ); 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 }; })();