Files
Aislo/A00_Common/line_stack_out.ts
T
eomsangdonandClaude Opus 5.5 3a8ba840f4 fix(845-2): sub2 단위 받은 뒤 표시 셋 — 「줄 따름」 = 고른 표 줄 단위 · 단위 TeX 글 안전(%) · 노임 원/일 × 인 = 원/인
- 「줄 따름」 표 값 = 지금 조건으로 고른 표 줄의 「단위」 칸(rowUnit · 화면 캐시 표 읽기) — 오른쪽 · 왼쪽 값 칸 · 수량 꼬리표
- 식 줄 결과 단위 = TeX 글 안전(「%」 가 주석으로 먹혀 「가산 = 0 %」 가 안 보이던 것)
- 단가 분모가 수량 단위와 같은 뜻이면 수량 글로(원/일 × 인 → 원/인 · ㎏ ↔ kg · ℓ ↔ L) · kWh = ㎾h · 기계 칸 전력 = ㎾h/hr(845-1 문서 단위와 같게)
- 왼쪽 만약이 수 하나로 접힌 식 줄 = 단위 남김
- 전수(845-1 단위 채움 뒤) 표시 탓 0 · 시험 test_845_2_unit_show 줄 따름 · % · 원/인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 23:01:15 +09:00

499 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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) ·
* 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만
* 값 = 계산기 셈 답 그대로(`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 { 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";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
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<string>;
toggle: (at: string) => void;
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
miss: () => void;
/** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */
cover?: Cover;
/** 838-3 기계 이름을 받은 뒤 다시 그림 */
redraw?: () => void;
}
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
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<TexCtx> = {},
) {
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<Row, "depth" | "lv">): 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<string, unknown>): 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<TexCtx>,
): (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<string, number>;
금액?: number | null;
까닭?: string;
단가?: unknown;
};
const scope: Values = { ...vals, ...moneyScope(a.줄, i) };
const at: Record<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 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 !== "") });
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
qty === null
? []
: [
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" });
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) = 글에만 금액 · 비목 칸 빈칸
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<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(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<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
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;
}