diff --git a/A00_Common/formula_doc.css b/A00_Common/formula_doc.css index c16118dcb..5d8fef9df 100644 --- a/A00_Common/formula_doc.css +++ b/A00_Common/formula_doc.css @@ -218,8 +218,9 @@ color: var(--color-text-secondary); } -/* 기호 정의 — 값 식 아래 한 줄씩 */ +/* 기호 정의 — 값 식 아래 한 줄씩(오른쪽 칸 자리) · 펼친 단가산출 블록만 격자 전체 폭 */ .fdoc__defs { + grid-column: 1 / -1; display: flex; flex-direction: column; gap: 2px; @@ -254,6 +255,13 @@ font-size: var(--text-body-sm); } +.fdoc__defs > .fdoc__colhead, +.fdoc__def, +.fdoc__defedit, +.fdoc__unused > summary { + margin-left: calc(50% + var(--spacing-24) / 2); +} + .fdoc__defedit { display: flex; flex-direction: column; @@ -294,6 +302,13 @@ .fdoc__grid { grid-template-columns: minmax(0, 1fr); } + + .fdoc__defs > .fdoc__colhead, + .fdoc__def, + .fdoc__defedit, + .fdoc__unused > summary { + margin-left: 0; + } } .fdoc__wrap, diff --git a/A00_Common/formula_doc_lines.ts b/A00_Common/formula_doc_lines.ts index 8b599accf..51ee5603f 100644 --- a/A00_Common/formula_doc_lines.ts +++ b/A00_Common/formula_doc_lines.ts @@ -15,6 +15,7 @@ import { el } from "@ui/ui_template_elements"; import { createSelectField } from "@ui/ui_template_input"; import type { DocVar, FormulaDoc, MoneyLine, Node } from "../common_util/common_util_formula_doc"; import type { DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { moneyScope } from "../common_util/common_util_formula_doc_view"; import { lineMap, litsOf, @@ -74,8 +75,15 @@ function vals(h: LinesHooks): Values { return got; } -/** 식 글 → 그림(못 풀면 글 그대로 빨강) */ -function expr(text: unknown, h: LinesHooks, side: "sym" | "val", inline = false, cost?: string) { +/** 식 글 → 그림(못 풀면 글 그대로 빨강) · scope = 금액 줄이 부르는 비목 합(앞 줄 누계) */ +function expr( + text: unknown, + h: LinesHooks, + side: "sym" | "val", + inline = false, + cost?: string, + scope?: Values, +) { const n = tryParse(text); if (!n) { const raw = span("fdoc__raw", String(text ?? "")); @@ -84,7 +92,14 @@ function expr(text: unknown, h: LinesHooks, side: "sym" | "val", inline = false, } const lits = side === "val" ? litsOf(n) : undefined; return { - node: renderNode(n, { side, vals: vals(h), vars: h.vars, inline, cost, lits }), + node: renderNode(n, { + side, + vals: scope ? { ...vals(h), ...scope } : vals(h), + vars: h.vars, + inline, + cost, + lits, + }), tree: n, }; } @@ -256,15 +271,16 @@ function moneyRow(i: number, h: LinesHooks): HTMLElement[] { } const right = el("div", { className: "fdoc__cell fdoc__valcell fdoc__money" }); const costs = line.비목 === "묶음" ? [...COSTS] : [null]; + const scope = moneyScope(h.answer.줄, i); for (const cost of costs) { const row = span("fdoc__mrow"); if (cost) row.append(span("fdoc__cost", cost), span("ui-eq__op", ":")); const pv = span("fdoc__edit"); - pv.append(expr(line.단가, h, "val", true, cost ?? undefined).node); + pv.append(expr(line.단가, h, "val", true, cost ?? undefined, scope).node); row.append(pv); if (hasQty) { const qv = span("fdoc__edit"); - qv.append(expr(line.수량, h, "val", true).node); + qv.append(expr(line.수량, h, "val", true, undefined, scope).node); row.append(span("ui-eq__op", "×"), qv); if (h.admin) qv.addEventListener("click", (ev) => onValueClick(ev, qv, h, "qty", i, () => undefined)); diff --git a/A00_Common/formula_doc_screen.ts b/A00_Common/formula_doc_screen.ts index e2518506a..23c98f891 100644 --- a/A00_Common/formula_doc_screen.ts +++ b/A00_Common/formula_doc_screen.ts @@ -521,7 +521,6 @@ export function createDocScreen(opt: DocScreenOptions) { el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }), el("div", { className: "fdoc__colhead", text: ft("Val_Head") }), ...lines, - el("div"), defs, ); }; diff --git a/common_util/common_util_formula_doc_view.ts b/common_util/common_util_formula_doc_view.ts index 456999f4a..1d5e4e712 100644 --- a/common_util/common_util_formula_doc_view.ts +++ b/common_util/common_util_formula_doc_view.ts @@ -137,3 +137,16 @@ export function takenOf(n: Node, vals: Values): 0 | 1 | 2 { const t = evalNode(n[2][0], vals); return t === undefined ? 0 : t ? 1 : 2; } + +/** 금액 줄 i 가 부르는 비목 합(노무비 · 재료비 · 경비 = 앞 줄 누계 · 합밖 · 제외 단계 줄 뺌) + * — 계산기 `money` 의 running 과 같은 값(셈 답 `줄` 의 비목에서 다시 모음 · 802-4b-3) */ +export function moneyScope(rows: unknown, i: number): Values { + const acc: Record = { 노무비: 0, 재료비: 0, 경비: 0 }; + ((rows ?? []) as { 비목?: Record; 합밖?: unknown }[]) + .slice(0, i) + .forEach((r) => { + if (r.합밖) return; + for (const k of Object.keys(acc)) if (isNum(r.비목?.[k])) acc[k] += r.비목![k] as number; + }); + return acc; +} diff --git a/resources/tester/helper_802_4b3_money.cjs b/resources/tester/helper_802_4b3_money.cjs new file mode 100644 index 000000000..43246da18 --- /dev/null +++ b/resources/tester/helper_802_4b3_money.cjs @@ -0,0 +1,65 @@ +/* 802-4b-3 금액 줄 값 식 빨강 헬퍼 — 화면 `formula_doc_lines.ts` 와 같은 값 목록(기호 값 + 금액 줄 기호 + 비목 합)으로 + * 금액 줄 단가 · 수량의 기호마다 값이 있는지 봄. stdin = [{문서, 값, 줄}] · stdout = [{빨강: [[줄, 기호]], 합: [[줄, 셈, 금액]]}] */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const view = load( + path.join(__dirname, "..", "..", "common_util", "common_util_formula_doc_view.ts"), +); +const COSTS = new Set(["노무비", "재료비", "경비"]); +const names = (n, out = []) => { + if (!n) return out; + if (n[0] === "var") out.push(n[1]); + else if (n[0] === "field") names(n[1], out); + else if (n[0] === "neg" || n[0] === "pct") names(n[1], out); + else if (n[0] === "bin") names(n[2], out) && names(n[3], out); + else if (n[0] === "call") n[2].forEach((x) => names(x, out)); + return out; +}; +const num = (v) => (typeof v === "string" && v !== "" && !isNaN(Number(v)) ? Number(v) : v); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const out = input.map(({ 문서, 값, 줄 }) => { + const vals = { ...값 }; + const lines = 문서.금액 || []; + lines.forEach((l, i) => { + const a = num((줄[i] || {}).금액); + if (l.기호 && typeof a === "number") vals[String(l.기호)] = a; + }); + const red = []; + const sums = []; + lines.forEach((l, i) => { + const amount = num((줄[i] || {}).금액); + const raw = view.moneyScope(줄, i); + const scope = { ...vals, ...raw }; + let cost = false; + for (const t of [l.단가, l.수량]) { + for (const name of names(view.tryParse(t))) { + if (COSTS.has(name)) cost = true; + const v = scope[name]; + const miss = v === null || v === undefined || (typeof v === "object" && l.비목 !== "묶음"); + if (miss && typeof amount === "number") red.push([i, name]); + } + } + if (cost && typeof amount === "number") { + const p = view.evalNode(view.tryParse(l.단가), scope); + const q = l.수량 === undefined || l.수량 === null || l.수량 === "" ? 1 : view.evalNode(view.tryParse(l.수량), scope); + sums.push([i, typeof p === "number" && typeof q === "number" ? p * q : null, amount]); + } + }); + return { 빨강: red, 합: sums }; +}); +process.stdout.write(JSON.stringify(out));