/* ============================================================================= * line_stack_out.ts * 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비) * 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 원/단위 × (수량 식) 단위 = 금액 원」 * 단위 = 규칙 한 벌(`common_util_line_stack_unit` · 845-2) · 기계경비식 겹분수 = 중간 줄(`common_util_line_stack_frac`) * 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를 * 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) · * 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만 * 기계 줄 작업량(846-2) = 「시간당 작업량 = …」 줄 먼저 · 비목 줄 「÷ 70 ㎥/시간」(`line_stack_work`) * 값 = 계산기 셈 답 그대로(`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, type Node as ExprNode, } 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 { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover"; import { decide, hasIf, lineMap, moneyScope, offLine, type Values, } from "../common_util/common_util_formula_doc_view"; import { machineText } from "./formula_doc_options"; 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 { splitFrac } from "../common_util/common_util_line_stack_frac"; import { stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick"; import { AMOUNT_UNIT, machineUnit, moneyUnits, unitText, shownUnitOf, } from "../common_util/common_util_line_stack_unit"; import { workAt } from "../common_util/common_util_line_stack_work"; import { partName } from "../common_util/common_util_machine_symbol_name"; import { katexNode, symTex, tex, txt, valTex, type TexCtx } from "./line_stack_tex"; import { lt } from "./line_stack_text"; import { workQty, workRows } from "./line_stack_work"; 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; /** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */ cover?: Cover; /** 838-3 기계 이름을 받은 뒤 다시 그림 */ redraw?: () => 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 look = (p: Record): unknown => h.cache.links.table(p); const unit = (id: string): string => shownUnitOf(vars.get(id), vals, look); // 845-2 단위 규칙 한 벌(줄 따름 = 고른 표 줄 단위) const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683) const machine = doc.부품?.갈래 === "기계경비식"; // 845-2 한 줄 분수 한 층(중간 줄로 나눔) const taken = new Set([...vars.keys(), ...lines.keys(), ...Object.values(alias)]); /** 수 글 — 고르기 · 코드 값(S_ · 직종 1048 등)은 쉼표 없이 */ const code = (id: string): boolean => /^S(_|$)/.test(id); const numText = (id: string, v: number): string => (code(id) ? String(v) : eqText(v)); /** 식 줄 「기호 = 기호식 = 값식 = 결과 단위」 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄) */ const formula = ( id: string, n: ExprNode, v: unknown, u: string, vs: Values, more: Partial, ): (string | HTMLElement)[] => { const res = isNum(v) ? katexNode(`${code(id) ? String(v) : valTex(v)}\\ ${u ? txt(u) : ""}`) // 단위 글 = TeX 안전(「%」 주석 · 「^」 첨자 막음) : typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름 ? el("span", { text: machineText(v, () => h.redraw?.()) ?? v }) : missing(a, id); const sym = tex(n, { side: "sym", vals: vs, alias, ...more }); const val = tex(n, { side: "val", vals: vs, alias, ...more }); const same = isNum(v) && val === valTex(v); return [ katexNode(symTex(alias[id] ?? id)), ...(sym === val ? [] : [" = ", katexNode(sym)]), ...(same ? [] : [" = ", katexNode(val)]), " = ", res, ]; }; 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 raw = subOf(id); if (!h.open.has(path) || !raw || depth >= MAX_DEPTH) return; const given = subInputs(vars.get(id)?.연결, vals); const sub = coverDoc(raw, given, h.cover); const got = calcDoc(sub, h.cache.links, given, [], new Map(), h.cover); 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, cls: "packs" }); // 칸 사이 = CSS 간격(줄이 바뀌어도 줄 앞 빈칸 없음 · 845-2) 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 n = nodeOf(t)!; if (!machine) { push({ h: formula(id, n, v, unit(id), vals, {}) }); continue; } // 845-2 기계경비식 — 겹분수 = 중간 줄(상여 · 휴지 · 손료계수) + 남는 안쪽 분수 ÷ 한 줄(그림만) const tree = hasIf(n) ? decide(n, vals) : n; const cut = splitFrac(id, tree, alias, taken, vals, partName(id)); const vs = { ...vals, ...cut.vals }; const more = { decided: true, frac: 1 }; // 중간 줄 단위 = 그 이름이 기계 칸(손료계수)이면 그 칸 단위 · 상여 · 휴지 = 비(무차원) for (const r of cut.rows) push({ h: formula(r.id, r.node, vs[r.id], machineUnit(r.id), vs, more) }); // prettier-ignore push({ h: formula(id, cut.node, v, unit(id), vs, more) }); continue; } const sub = subOf(id); if (sub || (v && typeof v === "object")) { flush(); const u = unit(id) || unitText(sub?.결과단위); const label = v && typeof v === "object" ? `${COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")} ${u}`.trim() : isNum(v) ? `${eqText(v)} ${u}`.trim() : "—"; 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) ? `${numText(id, v)} ${unit(id)}`.trim() : typeof v === "string" ? (machineText(v, () => h.redraw?.()) ?? 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; // 845-2 ㉢ 「단가 원/단위 × (수량 식) 단위 = 금액 원」 — 수량이 식이면 괄호로 식 전체에 단위 const ps = priceSym(line); const qn = qty === null ? null : nodeOf(qty); const mu = moneyUnits(line, nodeOf(line.단가), qn, ps ? vars.get(ps) : undefined, ps ? subOf(ps) : null); // prettier-ignore // 값 · 단위 한 덩이(좁은 폭에서 줄이 바뀌어도 단위가 떨어지지 않게) const glue = (...xs: (string | HTMLElement)[]): HTMLElement => el("span", { className: "lstk-pack", children: xs.filter((x) => x !== "") }); // 846-2 작업량 — 「시간당 작업량」 줄 먼저 · 수량 = 「÷ 시간당 작업량」 const w = workAt(a, i); const wq = (): HTMLElement[] | null => workQty(doc, qty, w, scope, alias); // 줄마다 새 요소 const byWork = wq() !== null; const workHead = (): void => { if (!w) return; const got = workRows(w, { doc, vals: scope, links: h.cache.links, miss: () => undefined, look }); // prettier-ignore if (h.cache.takeMissing()) h.miss(); for (const r of got) push(r); }; const qtyNode = (cost?: string): (string | HTMLElement)[] => qty === null ? [] : byWork ? [glue(" ", ...wq()!)] : [ glue( " × ", mu.wrap && qn ? katexNode(`\\left(${tex(qn, { side: "val", vals: scope, inline: true, cost, alias })}\\right)`) // prettier-ignore : exprNode(qty, "val", scope, true, cost, { alias }), mu.qty ? ` ${mu.qty}` : "", ), ]; const priceUnit = mu.price ? ` ${mu.price}` : ""; 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" }); workHead(); for (const c of COSTS) { const part = r.비목?.[c]; if (!isNum(part) || (!part && !bundle[c])) continue; cols[c] = part; push({ h: [`${COST_SHOW[c]} : `, glue(`${eqText(bundle[c])}${priceUnit}`), ...qtyNode(c), ` = ${one(part)} ${AMOUNT_UNIT}`], // prettier-ignore cols: { sum: part, [c]: part }, // 비목 줄 금액 = 합계 · 그 비목 칸(엑셀 단가산출 시트와 같음 · 835-1) }); } 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 }), ] : [glue(exprNode(line.단가, "val", scope, true, undefined, { alias }), priceUnit)]; const c = String(line.비목); const bare = !!line.합밖 && !line.비목; // 합 밖 소계(810-2) = 글에만 금액 · 비목 칸 빈칸 workHead(); push({ h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)} ${AMOUNT_UNIT}`], 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)} ${AMOUNT_UNIT}`], cols: { sum: s, ...sum }, cls: "subsum" }); // prettier-ignore } 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}${isNum(v) && body ? ` ${AMOUNT_UNIT}` : ""}`] }); } push({ h: [`${lt("Sum")} : ${isNum(a.계) ? `${won(a.계)} ${AMOUNT_UNIT}` : "—"}`], 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, cover: coverOf(doc) }, 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; }