/* ============================================================================= * line_stack_out.ts * 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비) * 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」 * 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를 * 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) · * 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만 * 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`) * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; import { eqText } from "@ui/ui_template_equation"; import { el } from "@ui/ui_template_elements"; import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc"; import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; import type { DocCache } from "../common_util/common_util_formula_doc_cache"; import { lineMap, moneyScope, offLine, type Values, } from "../common_util/common_util_formula_doc_view"; import { reach } from "./formula_doc_render"; import { kindOf, nodeOf, priceSym, stackOf, subInputs, type StackDoc, } from "../common_util/common_util_line_stack"; import { placeNames } from "../common_util/common_util_line_stack_copy"; import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick"; import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex"; import { lt } from "./line_stack_text"; type Cols = Partial>; interface Row { h: (string | HTMLElement)[]; cols?: Cols; cls?: string; won?: boolean; depth: number; /** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */ lv: number; /** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */ loc?: string; } export interface OutHooks { /** 줄 누름 — 왼쪽 짝 자리(679) */ pair?: (loc: string) => void; cache: DocCache; /** 펼친 자리(`부모/기호`) */ open: Set; toggle: (at: string) => void; /** 캐시에 없는 연결 — 뒤에서 서버에 물음 */ miss: () => void; } const COST_SHOW: Record = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" }; const won = (n: number): string => eqText(Math.round(n)); const one = (n: number): string => n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); const MAX_DEPTH = 3; const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」) /** 식 글 → 조판(못 풀면 글) */ function exprNode( text: unknown, side: "sym" | "val", vals: Values, inline = false, cost?: string, more: Partial = {}, ) { const n = nodeOf(text); return n ? katexNode(tex(n, { side, vals, inline, cost, ...more })) : el("span", { text: String(text ?? "") }); } const hasVar = (text: unknown): boolean => { const n = nodeOf(text); return !!n && JSON.stringify(n).includes('["var"'); }; function rows( doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: string, base = 0, ): Row[] { const out: Row[] = []; let lv = base + 1; // 지금 쌓는 줄 수준 let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만) const vals = (a.값 ?? {}) as Values; const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식])); // 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게) const push = (r: Omit): void => void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) }); const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만 const steps = lineMap(doc); const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5) const unit = (id: string): string => shownUnit(vars.get(id)?.단위); const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683) const subOf = (id: string): FormulaDoc | null => { const k = vars.get(id)?.연결; if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null; try { return h.cache.links.doc(k.키) ?? null; } catch { if (h.cache.takeMissing()) h.miss(); return null; } }; /** ▸ 단추 — 펼친 자리면 그 문서 줄을 뒤에 끼움 */ const opener = (id: string, label: string): HTMLElement => { const path = `${at}/${id}`; const b = createButton({ label: `${label} ${h.open.has(path) ? "▾" : "▸"}`, variant: "link", size: 20, onClick: () => h.toggle(path), }); b.dataset.expand = path; return b; }; /** 기계 · 단가산출 제목 줄 — 접기 단추 + 제목(누르면 펼침 · 요구 687-11) */ const fold = (id: string, title: string): HTMLElement[] => { const path = `${at}/${id}`; const on = h.open.has(path); const b = createIconButton({ icon: on ? "chevronDown" : "chevronRight", title: lt(on ? "Fold_Sub" : "Unfold_Sub"), size: 20, onClick: (e) => (e.stopPropagation(), h.toggle(path)), }); b.dataset.expand = path; const t = el("span", { className: "lstk-ftitle", text: title }); t.addEventListener("click", (e) => (e.stopPropagation(), h.toggle(path))); return [b, t]; }; const expand = (id: string): void => { const path = `${at}/${id}`; const sub = subOf(id); if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return; const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals)); if (h.cache.takeMissing()) h.miss(); out.push({ depth: depth + 1, lv: lv + 1, loc, h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`], cls: "subhead", }); out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc }))); }; /** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */ const block = (ids: string[]): void => { let pack: HTMLElement[] = []; const flush = (): void => { if (pack.length) push({ h: pack.flatMap((x, k) => (k ? [el("span", { className: "lstk-gap" }), x] : [x])) }); pack = []; }; for (const id of ids.filter((x) => shown.has(x))) { const v = vals[id]; const t = lines.get(id); const head = katexNode(symTex(alias[id] ?? id)); if (t !== undefined && hasVar(t)) { flush(); const res = isNum(v) ? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`) : missing(a, id); // 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄) const n = nodeOf(t)!; const sym = tex(n, { side: "sym", vals, alias }); const val = tex(n, { side: "val", vals, alias }); const same = isNum(v) && val === valTex(v); push({ h: [ head, ...(sym === val ? [] : [" = ", katexNode(sym)]), ...(same ? [] : [" = ", katexNode(val)]), " = ", res, ], }); continue; } const sub = subOf(id); if (sub || (v && typeof v === "object")) { flush(); const label = v && typeof v === "object" ? COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ") : isNum(v) ? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}` : "—"; push({ h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""], }); expand(id); continue; } const expr = t !== undefined && !isNum(Number(t)) ? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })] : []; const cell = el("span", { className: "lstk-pack", children: [ head, ...expr, " = ", isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id), ], }); pack.push(cell); if (pack.length >= 4) flush(); } flush(); }; const money = doc.금액 ?? []; const lineRows = (i: number): { sub: boolean; cols: Cols } => { const line = money[i]; const r = (a.줄?.[i] ?? {}) as { 비목?: Record; 금액?: number | null; 까닭?: string; 단가?: unknown; }; const scope: Values = { ...vals, ...moneyScope(a.줄, i) }; const at: Record = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5) money.slice(0, i).forEach((m, k) => { const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined; if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액 if (m.번지) for (const c of COSTS) { const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 }); if (isNum(got?.비목?.[c])) box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6; } }); if (Object.keys(at).length) scope.번지 = at; const spec = (line as { 규격?: string }).규격; const name = spec && !String(line.이름 ?? "").includes(spec) ? `${line.이름 ?? ""} ${spec}`.trim() : String(line.이름 ?? ""); // 이름에 규격이 들면 한 번만(814-1) const qty = line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null; const qtyNode = (cost?: string): (string | HTMLElement)[] => qty === null ? [] : [ " × ", exprNode(qty, "val", scope, true, cost, { alias }), line.단위 ? ` ${line.단위}` : "", ]; if (r.까닭 && r.금액 === null) { push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] }); return { sub: false, cols: {} }; } if (line.비목 === "묶음") { const s = priceSym(line); const bundle = (r.단가 ?? {}) as Values; const cols: Cols = {}; const title = name || (s ? (subOf(s)?.이름 ?? s) : ""); push({ h: s && subOf(s) ? fold(s, title) : [title], cls: "lname" }); for (const c of COSTS) { const part = r.비목?.[c]; if (!isNum(part) || (!part && !bundle[c])) continue; cols[c] = part; push({ h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`], }); } if (s) expand(s); return { sub: true, cols }; } const amt = r.금액 ?? 0; const price = hasVar(line.단가) && kindOf(doc, i) === "율" ? [ exprNode(line.단가, "sym", scope, true, undefined, { place, alias }), " = ", exprNode(line.단가, "val", scope, true, undefined, { alias }), ] : [exprNode(line.단가, "val", scope, true, undefined, { alias })]; const c = String(line.비목); const bare = !!line.합밖 && !line.비목; // 합 밖 소계(810-2) = 글에만 금액 · 비목 칸 빈칸 push({ h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`], cols: bare ? {} : { sum: amt, [c]: amt }, }); return { sub: false, cols: { [c]: amt } }; }; const items = stackOf(doc); const terms: Record = { 노무비: [], 재료비: [], 경비: [] }; const perItem: Record = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5) let n = 0; let sub = 0; const levels = stackLevels(items); for (const [k, it] of items.entries()) { lv = base + levels[k].item; loc = depth ? undefined : String(k); if (it.제목.trim()) { const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `; if (!it.가나다) sub = 0; push({ h: [label + it.제목], cls: it.가나다 ? "item sub" : "item" }); } const sum: Record = { 노무비: 0, 재료비: 0, 경비: 0 }; const own: Record = { 노무비: [], 재료비: [], 경비: [] }; let hasSub = false; lv = base + levels[k].line; for (const [j, l] of it.줄.entries()) { loc = depth ? undefined : `${k}.${j}`; if (l.꼴 === "글") push({ h: [l.글] }); else if (l.꼴 === "공식") block(l.기호); else { if (offLine(money[l.i], vals, steps)) continue; // 선택으로 꺼진 줄(수식 탭과 같음) const got = lineRows(l.i); hasSub ||= got.sub; if ((a.줄?.[l.i] as { 합밖?: boolean } | undefined)?.합밖) continue; for (const [c, v] of Object.entries(got.cols)) { sum[c] = Math.round((sum[c] + (v ?? 0)) * 10) / 10; if (v) own[c].push(v); } } } loc = depth ? undefined : String(k); if (hasSub && COSTS.some((c) => sum[c])) { const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10; push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" }); } for (const c of COSTS) { terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c])); if (sum[c]) perItem[c].push(sum[c]); } } lv = base + 1; loc = undefined; if (doc.결과) { const r = a.결과; push({ h: [ katexNode(symTex(doc.결과)), ` = ${isNum(r) ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—"}`, ], cls: "total", }); return out; } if (!money.length) return out; push({ h: [`${++n}. ${lt("Total")}`], cls: "item" }); lv = base + 2; for (const c of COSTS) { const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c]; const v = a[c]; const body = !isNum(v) ? "—" : t.length > 1 ? `${t.map(one).join(" + ")} = ${won(v)}` : t.length ? won(v) : ""; push({ h: [`${COST_SHOW[c]} : ${body}`] }); } push({ h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`], cols: isNum(a.계) ? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 } : {}, cls: "total", won: true, }); return out; } const missing = (a: DocAnswer, id: string): HTMLElement => el("span", { className: "lstk-miss", text: `— ${a.빈값?.[id] ?? ""}`.trim() }); /** 산출근거 표 */ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement { const columns: Column[] = [ { label: lt("Basis") }, { label: lt("Total"), width: "92px", align: "end" }, { label: COST_SHOW.노무비, width: "84px", align: "end" }, { label: COST_SHOW.재료비, width: "84px", align: "end" }, { label: COST_SHOW.경비, width: "84px", align: "end" }, ]; const { root, tbody } = tableShell(columns, { density: "data" }); const head: Row = { h: [`${doc.이름 ?? ""}${doc.결과단위 ? ` [${doc.결과단위}]` : ""}`], cols: isNum(a.계) ? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 } : {}, cls: "head", won: true, depth: 0, lv: 0, }; for (const r of [head, ...rows(doc, a, h, 0, "")]) { const cell = (k: "sum" | (typeof COSTS)[number]): string => { const v = r.cols?.[k]; if (v === undefined || (r.won && !v && k !== "sum")) return ""; return r.won ? won(v) : one(v); }; const first = el("div", { className: "lstk-h", children: r.h }); first.style.setProperty("--lstk-depth", String(r.lv)); first.dataset.lv = String(r.lv); const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]); tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`; if (r.loc !== undefined) { tr.dataset.loc = r.loc; tr.addEventListener("click", () => h.pair?.(r.loc!)); } tbody.append(tr); } root.classList.add("lstk-out"); return root; }