- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`) - 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로) - 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝 - 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김 - 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글 - 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음) - 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음 - 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록) - 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* 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<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;
|
||
}
|
||
|
||
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 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<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 name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
|
||
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.비목);
|
||
push({
|
||
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
|
||
cols: { 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)}`], 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;
|
||
}
|