feat(est): reproduce native tables and calculations

This commit is contained in:
2026-11-05 06:07:05 +09:00
parent 646eab6245
commit 60ccedcd9a
30 changed files with 5249 additions and 22 deletions
+274
View File
@@ -0,0 +1,274 @@
const EstExtra = (() => {
function field(section, index, key, value, type = "number") {
return `<input data-extra="${section}" data-row="${index}" data-field="${key}" type="${type}" step="any" value="${EstApp.esc(value)}" aria-label="${EstApp.esc(key)}">`;
}
function button(action, text, extra = "") {
return `<button data-action="${action}" ${extra}>${text}</button>`;
}
function table(headers, rows) {
return `<table class="sheet"><thead><tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
function box(title, tools, body, note = "") {
return `<div class="business-tools"><b>${title}</b>${tools}</div>${body}${note ? `<p class="business-note">${note}</p>` : ""}`;
}
function render(state) {
const { project, view } = state,
{ esc, number, byId } = EstApp;
let html = "";
if (view === "quantity") {
const values = Object.create(null),
rows = project.quantities.map((row, index) => {
let result,
error = "";
try {
result = EstMath.floating(
EstExpression.evaluate(row.formula || "0", (key) => values[key]),
row.digits ?? 2,
1,
);
values[row.Code] = result;
} catch (e) {
error = e.message;
}
return [
field("quantities", index, "Code", row.Code, "text"),
field("quantities", index, "Name", row.Name, "text"),
field("quantities", index, "TargetCode", row.TargetCode, "text"),
field("quantities", index, "formula", row.formula, "text"),
field("quantities", index, "digits", row.digits ?? 2),
esc(error || number(result)),
button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`),
];
});
html = box(
"수량산출서",
button("add-quantity", "산출 항목 추가") + button("apply-quantity", "내역에 수량 반영"),
table(["산출코드", "품명", "연결 내역코드", "수식", "소수자리", "결과", ""], rows),
"앞선 산출 항목을 [산출코드]로 참조합니다. 내역 반영은 연결된 코드의 산출값을 합산하여 수량에 반영합니다.",
);
} else if (view === "change") {
const changes = EstBusiness.changes(project);
html = box(
"설계변경",
button("capture-baseline", "현재 내역을 당초로 확정") +
button("recalculate-original", "당초 단가 재계산") +
button("bid-rates", "낙찰률 적용"),
table(
["코드", "품명", "당초 수량", "변경 수량", "당초 금액", "변경 금액", "증감"],
changes
.filter((row) => row.Code)
.map((row) => [
esc(row.Code),
esc(row.Name),
number(row.originalGN),
number(row.GN),
number(row.originalTC),
number(row.TC),
number(row.difference),
]),
),
project.baseline
? `당초: ${esc(project.baseline.name)}. 당초 자료는 별도 사본으로 보존합니다.`
: "현재 작업을 저장한 뒤 당초 내역을 확정하세요.",
);
} else if (view === "progress") {
state.roundId ||= project.progress[0]?.id;
const round = project.progress.find((r) => r.id === state.roundId);
let body = '<p class="empty">회차를 추가하세요.</p>',
tools = button("add-round", "기성 회차 추가");
if (round) {
tools +=
`<select id="round-choice">${project.progress.map((r) => `<option value="${esc(r.id)}" ${r.id === round.id ? "selected" : ""}>${esc(r.name)}${r.locked ? " (확정)" : ""}</option>`).join("")}</select>` +
button("lock-round", "회차 확정") +
`<label><input id="use-approved" type="checkbox" ${round.approved ? "checked" : ""} ${round.locked ? "disabled" : ""}> 누계에 사정 사용</label>`;
let output;
try {
output =
round.locked && round.result ? round.result : EstBusiness.progress(project, round.id);
} catch (e) {
output = { rows: [], errors: [e.message] };
}
state.business = output;
body =
table(
[
"코드",
"공종",
"도급수량",
"전회수량",
"금회 입력",
"입력방법",
"사정 입력",
"입력방법",
"금회금액",
"사정금액",
"누계수량",
"누계금액",
],
output.rows
.filter((r) => r.Code && !EstMath.num(r.Level))
.map((row, index) => {
const originalIndex = project.tables.EstMainDat.findIndex(
(r) => r.PkID === row.PkID && r.Code === row.Code,
),
key = EstBusiness.rowKey(row, originalIndex),
input = round.inputs?.[key] || {};
const edit = (prefix) =>
`<input data-progress="${prefix}" data-key="${esc(key)}" type="number" step="any" value="${input[prefix] ?? 0}" ${round.locked ? "disabled" : ""}>`;
const method = (prefix) =>
`<select data-progress="${prefix}Mode" data-key="${esc(key)}" ${round.locked ? "disabled" : ""}>${[
["quantity", "수량"],
["amount", "금액"],
["ratio", "비율 %"],
]
.map(
([v, label]) =>
`<option value="${v}" ${v === (input[`${prefix}Mode`] || "quantity") ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
return [
esc(row.Code),
esc(row.Name),
number(row.GN),
number(row.JunGN),
edit("Gum"),
method("Gum"),
edit("Sa"),
method("Sa"),
number(row.GumTC),
number(row.SaTC),
number(row.TotalGN),
number(row.TotalTC),
];
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
}
html = box(
"기성고",
tools,
body,
"전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.",
);
} else if (view === "escalation") {
const output = EstBusiness.escalation(project);
state.business = output;
const comp = state.escalationComponent || "NU";
const choices = EstMath.units
.map(
(key, i) =>
`<option value="${key}" ${key === comp ? "selected" : ""}>${["노무", "재료", "경비", "실적"][i]}</option>`,
)
.join("");
const rows = project.tables.EstMainDat.map((row, index) => ({ row, index }))
.filter(({ row }) => row.Code && !EstMath.num(row.Level))
.map(({ row, index }) => {
const old =
project.baseline?.tables.EstMainDat.find(
(r) => r.PkID === row.PkID && r.Code === row.Code,
) || row,
result = output.rows[index];
return [
esc(row.Code),
esc(row.Name),
EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]),
number(old[comp]),
number(row[comp]),
number(result[`D${comp}`]),
number(result[`E${comp}`]),
number(result[`F${comp}`]),
EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0),
EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0),
EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0),
number(result.EscExcluded),
number(result.EscApplied),
];
});
html = box(
"물가변동 품목조정",
`<select id="escalation-component">${choices}</select>` +
button("capture-bid", "현재 단가를 입찰단가로 기록") +
button("capture-baseline", "계약 내역 확정"),
table(
[
"코드",
"품명",
"입찰 당시",
"계약 단가",
"변동 당시",
"등락률 %",
"등락폭",
"조정단가",
"예정공정수량",
"실행공정수량",
"기성수량",
"적용제외",
"적용수량",
],
rows,
),
`계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "annual") {
const output = EstBusiness.annual(project);
state.business = output;
html = box(
"연차공사",
button("add-annual", "차수 추가"),
table(
["차수", "수량 배분 수식", "순공사비", "도급액", "작업", ""],
project.annual.map((year, index) => [
field("annual", index, "name", year.name, "text"),
field("annual", index, "formula", year.formula, "text"),
number(output.results[index]?.project.calculation.direct.TC),
number(output.results[index]?.project.calculation.total),
button("open-annual", "차수 내역 열기", `data-row="${index}"`),
button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`),
]),
),
`수식 예: [계약수량]*0.5, [계약수량]-[1차]. ${esc(output.errors.join(" / "))}`,
);
} else if (view === "resources") {
const output = EstResources.calculate(project);
html = box(
"동원인원·자재·장비",
"",
table(
["종류", "코드", "품명", "규격", "단위", "소요량 / 환산시간", "금액"],
output.rows.map((row) => [
esc({ 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" }[row.kind]),
esc(row.Code),
esc(row.Name),
esc(row.Size),
esc(row.Unite),
number(row.quantity),
number(row.amount),
]),
),
esc(output.errors.join(" / ")),
);
} else if (view === "drawing") {
const rows = project.tables.CadMainDat || [];
html = box(
"원본 도면·문서 자료",
"",
table(
["번호", "코드", "이름", "보존 필드"],
rows.map((row, index) => [
index + 1,
esc(row.Code),
esc(row.Name),
esc(
Object.keys(row)
.filter((key) => row[key]?.binary)
.join(", "),
),
]),
),
"가져온 도면 바이너리는 프로젝트에 보존됩니다. VEC0650/CAD 편집·출력의 재현 근거는 현재 자료에 없어 동작을 구현하지 않았습니다.",
);
}
byId("work-content").innerHTML = html;
}
return { render };
})();