- 산출근거: 일당 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」 · 시간당 식 = q · K · f · E · cm 값 줄 + 한 층 분수 Q 식 · 비목 줄 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」(금액 그대로) - 제어 구역: 「시간당 작업량」 묶음 맨 위 — 방식 고르기(식 없는 기계 = 잠김 + 까닭) · 시간당 = 작업량 틀 변수 칸(고침 · 원래 값) · 덮음 = 기계덮음.<기계>.작업량 - 기계 보기: 왼쪽 작업량 카드(기계 정본 변수) · 오른쪽 풀이 · 서버가 작업량 틀 연결을 실음(M02_MyMachine_Sheet._work) - 시험: test_846_2_work_view 새로 · test_845_2 GC000088 비목 줄 기대값을 새 꼴로 고침(846-2 표시 변경) · 헬퍼 가짜 DOM 이 진짜 DOM 처럼 요소를 옮김(같은 요소 두 줄 재사용 흠을 잡게) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
516 lines
22 KiB
TypeScript
516 lines
22 KiB
TypeScript
/* =============================================================================
|
||
* 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<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 !== "") });
|
||
// 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<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;
|
||
}
|