260 lines
12 KiB
JavaScript
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 };
|
|
})();
|