diff --git a/A00_Common/formula_doc_options.ts b/A00_Common/formula_doc_options.ts index 5ba453f62..414cb4e64 100644 --- a/A00_Common/formula_doc_options.ts +++ b/A00_Common/formula_doc_options.ts @@ -9,6 +9,7 @@ import { createButton } from "@ui/ui_template_button"; import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; +import { varUnit } from "../common_util/common_util_line_stack_unit"; import { cfMachineNames, cfMachineOff } from "../M02_MasterTemplete/DataFlow/M02_DF_Api"; import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text"; import { ft } from "./formula_doc_text"; @@ -101,7 +102,7 @@ export function buildOptions( value: now === undefined || now === null ? "" : String(now), compact: true, size: 28, - unit: v.단위 || undefined, + unit: varUnit(v) || undefined, // 845-2 단위 규칙 한 벌 }); f.input.disabled = lock; f.input.addEventListener("change", () => { diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index 5af79b1bc..332d9af32 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -378,11 +378,29 @@ display: flex; flex-wrap: wrap; align-items: center; - gap: 0 var(--spacing-4); + gap: var(--spacing-4); padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16)); white-space: normal; } +/* 845-2 휴대폰 폭 — 금액 칸 넷은 줄 글(「= 843.1 원」) · 아래 합계 상자와 같아 걷고 산출근거 칸을 다 씀(가로 넘침 없음) */ +@container (max-width: 560px) { + .lstk-out th:nth-child(n + 2), + .lstk-out td:nth-child(n + 2) { + display: none; + } +} + +/* 845-2 분수 줄 — 한글 분자 · 분모가 KaTeX 높이 셈보다 커서 위아래 줄에 닿음 → 위아래 여유 */ +.lstk-out .lstk-h:has(.mfrac) { + padding-block: var(--spacing-8); +} + +/* 값 칸 묶음 줄 — 칸 사이 간격(줄이 바뀌어도 줄 앞 빈칸 없음) */ +.lstk-out tr.packs .lstk-h { + column-gap: var(--spacing-24); +} + .lstk-out tr.head, .lstk-out tr.item, .lstk-out tr.total { @@ -408,11 +426,6 @@ cursor: pointer; } -.lstk-gap { - display: inline-block; - width: var(--spacing-24); -} - .lstk-pack { display: inline-flex; align-items: center; @@ -487,10 +500,20 @@ .lstk-mctl > .lstk-frow { display: grid; - grid-template-columns: 7em auto 11em auto auto; + grid-template-columns: minmax(3em, 6em) auto minmax(0, 15em) auto auto; justify-content: start; } +/* 845-2 값 칸 + 단위(칸 밖 바로 뒤) — 좁으면 칸이 줄어듦 */ +.lstk-mctl > .lstk-frow > .lstk-qty { + min-width: 0; +} + +.lstk-mctl .lstk-unit { + flex: none; + white-space: nowrap; +} + .lstk-mctl > .lstk-frow > .lstk-sym { max-width: none; } diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 4dcba0e72..0dfc1c650 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -44,10 +44,10 @@ import { type Picked, aliasText, repick, - shownUnit, tableAlias, unswap, } from "../common_util/common_util_line_stack_pick"; +import { varUnit } from "../common_util/common_util_line_stack_unit"; import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick"; import { reach } from "./formula_doc_render"; import { attachDrag } from "./line_stack_drag"; @@ -326,7 +326,7 @@ function qtyBox( const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text))); const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null; if (tag && isNum(val)) - tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`; + tag.title = `${eqText(val)}${varUnit(v) ? ` ${varUnit(v)}` : ""} — ${tag.title || tag.textContent}`; return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] }); } diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts index ceb50145a..e050d1b72 100644 --- a/A00_Common/line_stack_machine_ctl.ts +++ b/A00_Common/line_stack_machine_ctl.ts @@ -31,6 +31,7 @@ import { } from "../common_util/common_util_formula_doc_cover"; import { subInputs, type StackDoc } from "../common_util/common_util_line_stack"; import type { Picked } from "../common_util/common_util_line_stack_pick"; +import { machineUnit } from "../common_util/common_util_line_stack_unit"; import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import { openStackPick } from "./line_stack_pick"; @@ -52,11 +53,6 @@ export interface CtlHooks { lock?: string[]; } -/** 기계 칸 단위(기계 표 `단위` 와 같음) */ -const UNIT: Record = { - 가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr", - 주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr", -}; // prettier-ignore const FUELS = ["경유", "휘발유", "실내등유"]; /** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */ @@ -83,7 +79,7 @@ const num = (t: string): number | null => { return t.trim() !== "" && Number.isFinite(n) ? n : null; }; -/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */ +/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 · 단위 = 칸 바로 뒤(공식 묶음 값 칸과 같은 자리 · 845-2 ㉥) */ function numBox( label: string, value: unknown, @@ -97,7 +93,6 @@ function numBox( placeholder: hint, compact: true, size: 24, - ...(unit ? { unit } : {}), }); f.root.classList.add("lstk-f-s"); f.input.setAttribute("aria-label", label); @@ -113,7 +108,9 @@ function numBox( } set(n); }); - return f.root; + return unit + ? el("span", { className: "lstk-qty", children: [f.root, el("span", { className: "lstk-unit", text: unit })] }) // prettier-ignore + : f.root; } const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text }); @@ -154,7 +151,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen ariaLabel: col, onChange: (x) => setVal(x), }).root - : numBox(col, now, UNIT[col] ?? "", shut, setVal); + : numBox(col, now, machineUnit(col), shut, setVal); // 845-2 기계 표 칸 단위 한 벌 const row = el("div", { className: "lstk-frow", children: [ diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts index fad4d8106..7fc60c0bc 100644 --- a/A00_Common/line_stack_machine_sheet.ts +++ b/A00_Common/line_stack_machine_sheet.ts @@ -54,6 +54,12 @@ import { type MachineRow, type MachineValues, } from "../common_util/common_util_machine_sheet"; +import { + MACHINE_UNIT, + machineUnit, + unitText, + varUnit, +} from "../common_util/common_util_line_stack_unit"; import { machineCellName } from "../common_util/common_util_machine_symbol_name"; import { docFoot } from "./formula_doc_foot"; import { buildMachineCtl } from "./line_stack_machine_ctl"; @@ -108,14 +114,20 @@ interface SheetInfo { /** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */ const BASE_KEYS = ["가격", "손료계수", "주연료", "주연료종류", "전력"]; -const UNIT: Record = { - 가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", 전력: "kWh", -}; // prettier-ignore const PICKS = ["주연료종류"]; /** 식이 바뀔 수 있는 칸(서버 식 고름) */ const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS]; const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; +/** 값 박은 식(그림용) — 박기 전 기계 칸 단위를 남김(박으면 칸 연결이 수로 바뀌어 단위를 못 읽음 · 845-2) */ +const boundView = (d: FormulaDoc, raw: MachineValues): FormulaDoc => { + const b = bindDoc(d, raw); + b.변수?.forEach((v, i) => { + const u = varUnit(d.변수?.[i]); + if (u && !v.단위) v.단위 = u; + }); + return b; +}; const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); const label = (k: string): string => machineCellName(k); @@ -278,10 +290,10 @@ export function createMachineSheet(o: MachineSheetOptions) { ctlBox.replaceChildren(...(zone ? [zone] : [])); }; - /** 칸 단위 — 줄 `단위` 글(일 단위 손료 등) › 기본 */ + /** 칸 단위 — 줄 `단위` 글(일 단위 손료 등 · 기계 표 기본과 다를 때) › 기계 표 칸 단위(845-2 한 벌) */ const unitOf = (k: string): string => { const u = text((cur.단위 as MachineValues | undefined)?.[k]); - return !u || u === "10^-7/시간" ? (UNIT[k] ?? "") : u; + return u && u !== MACHINE_UNIT[k] ? unitText(u) : machineUnit(k); }; const numField = (k: string, read = false): HTMLElement => { const v = cur.값?.[k]; @@ -504,7 +516,7 @@ export function createMachineSheet(o: MachineSheetOptions) { right.replaceChildren( head, ...(info.까닭 ? [createText(info.까닭, "warning")] : []), - buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, { + buildOut(boundView(shaped, info.값 ?? {}) as StackDoc, a, { cache, open: opened, toggle: (at) => { @@ -520,7 +532,7 @@ export function createMachineSheet(o: MachineSheetOptions) { }; const drawWarn = (): void => { const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) }); - if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님 + if (unitOf("손료계수") !== machineUnit("손료계수")) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님 warn.replaceChildren( ...rangeWarnings(vals).map(([k, r]) => createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"), diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index e1b0642b7..b1649a7e5 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -1,7 +1,8 @@ /* ============================================================================= * 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) · * 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만 @@ -12,11 +13,18 @@ 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 { + 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, @@ -33,7 +41,16 @@ import { 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 { 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, + varUnit, +} from "../common_util/common_util_line_stack_unit"; +import { partName } from "../common_util/common_util_machine_symbol_name"; import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex"; import { lt } from "./line_stack_text"; @@ -110,8 +127,38 @@ function rows( 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 unit = (id: string): string => varUnit(vars.get(id)); // 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 ? `\\text{${u}}` : ""}`) + : 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; @@ -171,8 +218,7 @@ function rows( 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])) }); + if (pack.length) push({ h: pack, cls: "packs" }); // 칸 사이 = CSS 간격(줄이 바뀌어도 줄 앞 빈칸 없음 · 845-2) pack = []; }; for (const id of ids.filter((x) => shown.has(x))) { @@ -181,35 +227,30 @@ function rows( 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)}}` : ""}`) - : typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름 - ? el("span", { text: machineText(v, () => h.redraw?.()) ?? v }) - : 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, - ], - }); + 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(" · ") + ? `${COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")} ${u}`.trim() : isNum(v) - ? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}` + ? `${eqText(v)} ${u}`.trim() : "—"; push({ h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""], @@ -228,7 +269,7 @@ function rows( ...expr, " = ", isNum(v) - ? `${eqText(v)} ${unit(id)}` + ? `${numText(id, v)} ${unit(id)}`.trim() : typeof v === "string" ? (machineText(v, () => h.redraw?.()) ?? v) : missing(a, id), @@ -269,14 +310,26 @@ function rows( : 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 ? [] : [ - " × ", - exprNode(qty, "val", scope, true, cost, { alias }), - line.단위 ? ` ${line.단위}` : "", + 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: {} }; @@ -292,7 +345,7 @@ function rows( if (!isNum(part) || (!part && !bundle[c])) continue; cols[c] = part; push({ - h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`], + h: [`${COST_SHOW[c]} : `, glue(`${eqText(bundle[c])}${priceUnit}`), ...qtyNode(c), ` = ${one(part)} ${AMOUNT_UNIT}`], // prettier-ignore cols: { sum: part, [c]: part }, // 비목 줄 금액 = 합계 · 그 비목 칸(엑셀 단가산출 시트와 같음 · 835-1) }); } @@ -307,11 +360,11 @@ function rows( " = ", exprNode(line.단가, "val", scope, true, undefined, { 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)}`], + h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)} ${AMOUNT_UNIT}`], cols: bare ? {} : { sum: amt, [c]: amt }, }); return { sub: false, cols: { [c]: amt } }; @@ -353,7 +406,7 @@ function rows( 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" }); + 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])); @@ -386,10 +439,10 @@ function rows( : t.length ? won(v) : ""; - push({ h: [`${COST_SHOW[c]} : ${body}`] }); + push({ h: [`${COST_SHOW[c]} : ${body}${isNum(v) && body ? ` ${AMOUNT_UNIT}` : ""}`] }); } push({ - h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`], + h: [`${lt("Sum")} : ${isNum(a.계) ? `${won(a.계)} ${AMOUNT_UNIT}` : "—"}`], cols: isNum(a.계) ? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 } : {}, diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 38086baee..0973c6e8f 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -42,9 +42,9 @@ import { boundConds, currentRow, pickCell, - shownUnit, tableAlias, } from "../common_util/common_util_line_stack_pick"; +import { unitText, varUnit } from "../common_util/common_util_line_stack_unit"; import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick"; import type { EditHooks } from "./line_stack_edit"; import { openTablePick } from "./line_stack_pick"; @@ -161,12 +161,12 @@ export function buildBlock( const v = vals[id]; const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름 if (mach) return mach; - if (isNum(v) || typeof v === "string") - return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim(); - if (v && typeof v === "object") - return ["노무비", "재료비", "경비"] - .map((c) => `${c} ${eqText((v as Record)[c])}`) - .join(" · "); + if (isNum(v) || typeof v === "string") return `${eqText(v)} ${varUnit(vars.get(id))}`.trim(); + if (v && typeof v === "object") { + const key = vars.get(id)?.연결?.키; // 845-2 묶음 값 = 그 문서 결과 단위(원/시간 등) + const u = varUnit(vars.get(id)) || unitText(key ? h.subDoc(key)?.결과단위 : ""); + return `${["노무비", "재료비", "경비"].map((c) => `${c} ${eqText((v as Record)[c])}`).join(" · ")} ${u}`.trim(); // prettier-ignore + } return `— ${h.answer.빈값?.[id] ?? ""}`.trim(); }; @@ -350,9 +350,7 @@ export function buildBlock( input(shownText(valueOf), valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, realText(t))), ), - ...(shownUnit(v?.단위) - ? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })] - : []), + ...(varUnit(v) ? [el("span", { className: "lstk-unit", text: varUnit(v) })] : []), ]; const why = iffy ? whyText(tree!, vals, { diff --git a/A00_Common/line_stack_tex.ts b/A00_Common/line_stack_tex.ts index e8cefd828..9e9a64901 100644 --- a/A00_Common/line_stack_tex.ts +++ b/A00_Common/line_stack_tex.ts @@ -25,6 +25,8 @@ export interface TexCtx { alias?: Record; /** 이미 결정된 나무(다시 안 접음) */ decided?: boolean; + /** 남은 분수 층(845-2 — 0 이면 안쪽 ÷ 는 한 줄 · 없음 = 끝없음) */ + frac?: number; } const HIDE = new Set(["반올림", "올림", "버림"]); @@ -124,10 +126,16 @@ export function tex(n: Node, c: TexCtx): string { } case "bin": { const [, o, a, b] = n; - if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`; + if (o === "/" && !c.inline && c.frac !== 0) { + const d = { ...c, frac: c.frac === undefined ? undefined : c.frac - 1 }; + return `\\dfrac{${tex(a, d)}}{${tex(b, d)}}`; + } const mine = PREC[o]; + // 845-2 층 상한(기계경비식) — 분수로 그리는 오른쪽 항은 괄호 없이(분수 선이 묶음) + const frac = (x: Node): boolean => + c.frac !== undefined && c.frac > 0 && !c.inline && x[0] === "bin" && x[1] === "/"; const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a); - const right = prec(b) <= mine ? paren(go(b)) : go(b); + const right = prec(b) <= mine && !frac(b) ? paren(go(b)) : go(b); return `${left} ${OPS[o] ?? txt(o)} ${right}`; } } diff --git a/common_util/common_util_line_stack_frac.ts b/common_util/common_util_line_stack_frac.ts new file mode 100644 index 000000000..24222818c --- /dev/null +++ b/common_util/common_util_line_stack_frac.ts @@ -0,0 +1,134 @@ +/* ============================================================================= + * common_util_line_stack_frac.ts + * 산출근거 분수 층(PLAN 845-2 · 요구 880 「분모 · 분자가 쌓여 층층이 → 행으로 나눔」) + * 층 = 조판(`line_stack_tex`)과 같은 셈 — ÷ 하나 = 분수 한 층 · 감싸개(반올림 · 올림 · 버림)는 안 셈 + * 기계경비식(`splitFrac`) = 한 줄에 분수 한 층까지: + * ① 곱 · 나눗셈 사슬에 ÷ 가 둘 넘으면 「a ÷ b」 를 짝으로 나란히(겹 대신 · 값 같음) + * ② 「…분자 ÷ …분모」 짝(그림 이름 머리가 줄 이름과 다름) = 중간 줄 「상여 = 16 ÷ 12」 + * ③ 분수 안 덧셈 묶음이 또 분수를 품으면 = 중간 줄(그림 사전 `partName` — 「손료계수」) + * ④ 그래도 남는 안쪽 분수 = 조판이 ÷ 한 줄로(`TexCtx.frac`) + * 그리기만 — 문서 식 · 값 · 금액 불변 · 중간 줄 값 = 같은 식 조각을 같은 셈(`evalNode`) · 화면 없음(시험이 Node 로 돌림) + * ========================================================================== */ + +import type { Node } from "./common_util_formula_doc"; +import { evalNode, type Values } from "./common_util_formula_doc_view"; + +const HIDE = new Set(["반올림", "올림", "버림"]); +const hidden = (n: Node): n is ["call", string, Node[]] => + n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2; + +/** 조판 분수 층 수 */ +export function fracDepth(n: Node): number { + switch (n[0]) { + case "neg": + case "pct": + return fracDepth(n[1]); + case "call": { + const items = n[2]; + return hidden(n) ? fracDepth(items[0]) : Math.max(0, ...items.map(fracDepth)); + } + case "bin": { + const d = Math.max(fracDepth(n[2]), fracDepth(n[3])); + return n[1] === "/" ? d + 1 : d; + } + default: + return 0; + } +} + +/** 왼쪽으로 쌓인 곱 · 나눗셈 사슬 → [연산, 항] */ +function chain(n: Node): ["*" | "/", Node][] { + if (n[0] === "bin" && (n[1] === "*" || n[1] === "/")) + return [...chain(n[2]), [n[1] as "*" | "/", n[3]]]; + return [["*", n]]; +} +const product = (xs: Node[]): Node => xs.reduce((a, b) => ["bin", "*", a, b]); +const isFrac = (n: Node): n is ["bin", string, Node, Node] => n[0] === "bin" && n[1] === "/"; + +/** ① ÷ 둘 넘는 사슬 = 「a ÷ b」 짝을 나란히 — 감싸개 · 괄호 안까지 */ +export function regroup(n: Node): Node { + switch (n[0]) { + case "neg": + return ["neg", regroup(n[1])]; + case "pct": + return ["pct", regroup(n[1])]; + case "call": + return ["call", n[1], n[2].map(regroup)]; + case "bin": { + const items = chain(n); + if (items.length < 2 || items.filter(([o]) => o === "/").length < 2) + return ["bin", n[1], regroup(n[2]), regroup(n[3])]; + const out: Node[] = []; + for (const [o, raw] of items) { + const x = regroup(raw); + const last = out[out.length - 1]; + if (o === "*" || !last) out.push(x); + else if (isFrac(last)) + out[out.length - 1] = ["bin", "/", last[2], ["bin", "*", last[3], x]]; + else out[out.length - 1] = ["bin", "/", last, x]; + } + return product(out); + } + default: + return n; + } +} + +export interface FracRow { + /** 중간 줄 이름(그림 전용 — 문서 기호 아님) */ + id: string; + node: Node; +} + +const numOk = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +/** 기계경비식 한 줄 → 중간 줄 + 줄인 식 · 중간 값(`vals` 에 얹을 것) */ +export function splitFrac( + id: string, + n: Node, + alias: Record, + taken: Set, + vals: Values, + part?: string, +): { rows: FracRow[]; node: Node; vals: Values } { + const rows: FracRow[] = []; + const got: Values = {}; + const name = (x: string): string => alias[x] ?? x; + const own = name(id); + const take = (nm: string, sub: Node): Node | null => { + if (!nm || taken.has(nm) || nm in got) return null; + const v = evalNode(sub, { ...vals, ...got }); + if (!numOk(v)) return null; + got[nm] = v; + rows.push({ id: nm, node: sub }); + return ["var", nm]; + }; + // 감싸개(버림 등)는 그대로 두고 안쪽만 + const inner = (x: Node, fn: (y: Node) => Node): Node => + hidden(x) ? ["call", x[1], [inner(x[2][0], fn), ...x[2].slice(1)]] : fn(x); + let node = inner(regroup(n), (x) => { + // ② 분자 · 분모 짝(곱 사슬의 분수 항) + const factors = (y: Node): Node[] => + y[0] === "bin" && y[1] === "*" ? [...factors(y[2]), y[3]] : [y]; + const parts = factors(x).map((f) => { + if (!isFrac(f) || f[2][0] !== "var" || f[3][0] !== "var") return f; + const a = /^(.+)분자$/.exec(name(f[2][1])); + const b = /^(.+)분모$/.exec(name(f[3][1])); + return a && b && a[1] === b[1] && a[1] !== own ? (take(a[1], f) ?? f) : f; + }); + return product(parts); + }); + // ③ 분수 안 덧셈 묶음이 분수를 품음 = 중간 줄(사전 이름 있을 때만) + if (part && fracDepth(node) > 1) { + const dig = (x: Node, inFrac: boolean): Node => { + if (x[0] === "bin" && (x[1] === "+" || x[1] === "-") && inFrac && fracDepth(x) >= 1) + return take(part, x) ?? x; + if (x[0] === "bin") return ["bin", x[1], dig(x[2], inFrac || x[1] === "/"), dig(x[3], inFrac || x[1] === "/")]; // prettier-ignore + if (x[0] === "call") return ["call", x[1], x[2].map((y) => dig(y, inFrac))]; + if (x[0] === "neg" || x[0] === "pct") return [x[0], dig(x[1], inFrac)] as Node; + return x; + }; + node = dig(node, false); + } + return { rows, node, vals: got }; +} diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts index 165faf65f..b204037af 100644 --- a/common_util/common_util_line_stack_pick.ts +++ b/common_util/common_util_line_stack_pick.ts @@ -250,6 +250,3 @@ export function aliasText(text: string, alias: Record, back = fa t = String(back ? renameText(t, now, id) : renameText(t, id, now)); return t; } - -/** 표 칸 단위 「줄 따름」(줄마다 다른 단위) = 화면에 안 보임(요구 683) */ -export const shownUnit = (u: unknown): string => (u === "줄 따름" ? "" : String(u ?? "")); diff --git a/common_util/common_util_line_stack_unit.ts b/common_util/common_util_line_stack_unit.ts new file mode 100644 index 000000000..78ab2a8ee --- /dev/null +++ b/common_util/common_util_line_stack_unit.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * common_util_line_stack_unit.ts + * 단가산출 단위 그리는 규칙 한 벌(PLAN 845-2 · 요구 880) — 왼쪽 편집 · 오른쪽 산출근거 · 기계 제어 구역 · 기계 보기가 같이 씀 + * ㉠ 값 줄 = 값 뒤 단위(`varUnit`) ㉡ 식 줄 = 결과 값 뒤만(식 가운데 숫자 뒤 없음) + * ㉢ 비목 · 금액 줄 = 「단가 원/단위 × (수량 식) 단위 = 금액 원」(`moneyUnits` — 수량이 식이면 괄호로 식 전체에 단위) · + * 소계 · 합계 = 원(머리 [원/㎥] = 결과 하나당) + * ㉣ 무차원 = 빈 칸 ㉤ 율 = %(「%(인력품 대비)」 등 설명 괄호 뗌) ㉥ 왼쪽 값 칸 = 칸 바로 뒤 한 자리 + * 메타 단위(「구분 따름」 · 「줄 따름」 · 「수(…)」 · 「원문 단위 없음」) = 단위 아님 → 안 그림 · 「10^-7」 = 10⁻⁷ + * 기계 칸 연결(요소 `EQ:` 칸) 변수 = 변수 단위가 빈 칸이면 기계 표 칸 단위(`MACHINE_UNIT` — 기계 표 빌더 · 내 기계와 같은 글) + * 그리기만 — 자료 · 식 · 금액 불변 · 화면 없음(시험이 Node 로 돌림) + * ========================================================================== */ + +import type { DocVar, MoneyLine, Node } from "./common_util_formula_doc"; + +/** 기계 표 칸 단위(`build_기계_건설품셈.py` UNIT · `M02_MyMachine.py` UNITS 와 같은 글) */ +export const MACHINE_UNIT: Record = { + 가격: "천원", + 내용시간: "시간", + 연간표준가동시간: "시간", + 상각비계수: "10^-7/시간", + 정비비계수: "10^-7/시간", + 관리비계수: "10^-7/시간", + 손료계수: "10^-7/시간", + 주연료: "ℓ/hr", + 전력: "kWh/hr", + 잡재료율: "%", + 잡품: "%", + 조종원: "인/일", + 인력: "인/일", + 노무비: "원/시간", + 재료비: "원/시간", + 경비: "원/시간", +}; + +/** 금액 · 소계 · 합계 단위 */ +export const AMOUNT_UNIT = "원"; + +/** 자료 단위 글 → 화면 단위(메타 = 빈 글) */ +export function unitText(u: unknown): string { + const s = String(u ?? "").trim(); + if (!s || /따름|원문 단위 없음/.test(s) || /^수(\(| 또는|$)/.test(s)) return ""; + return s.replace(/^%\(.*\)$/, "%").replace(/10\^-7/g, "10⁻⁷"); +} + +/** 기계 칸 이름의 화면 단위 */ +export const machineUnit = (col: string): string => unitText(MACHINE_UNIT[col]); + +/** 변수 화면 단위 — 변수 단위 › 기계 칸 연결이면 기계 표 칸 단위 */ +export function varUnit(v: DocVar | undefined): string { + const own = unitText(v?.단위); + if (own) return own; + const k = v?.연결; + return k?.갈래 === "요소" && /^EQ:/.test(String(k.키 ?? "")) && k.칸 ? machineUnit(k.칸) : ""; +} + +const HIDE = new Set(["반올림", "올림", "버림"]); +/** 한 덩이(기호 · 수 · 칸 — 감싸개 안 한 덩이 포함) */ +export function isAtom(n: Node | null | undefined): boolean { + if (!n) return false; + if (n[0] === "call" && HIDE.has(n[1]) && n[2].length >= 1 && n[2].length <= 2) + return isAtom(n[2][0]); + return n[0] === "var" || n[0] === "num" || n[0] === "field" || n[0] === "str"; +} + +export interface MoneyUnits { + /** 단가 뒤(원/시간 · 원/인 …) — 단가가 한 덩이이고 수량이 있을 때만 */ + price: string; + /** 수량 식 뒤 */ + qty: string; + /** 수량 식을 괄호로 감쌈(식이고 단위가 있을 때) */ + wrap: boolean; +} + +/** 금액 줄 단위 자리(㉢) — `priceVar` = 단가 기호 변수 · `sub` = 묶음 · 단가산출 줄이 부르는 문서 */ +export function moneyUnits( + line: MoneyLine, + price: Node | null, + qty: Node | null, + priceVar?: DocVar, + sub?: { 결과단위?: string } | null, +): MoneyUnits { + const has = line.수량 !== undefined && line.수량 !== null && line.수량 !== ""; + const q = has ? unitText(line.단위) : ""; + const wrap = !!q && !!qty && !isAtom(qty); + const own = [sub?.결과단위, priceVar?.단위].map(unitText).find((u) => u.startsWith("원")); + const p = has && isAtom(price) ? (own ?? (q ? `${AMOUNT_UNIT}/${q}` : "")) : ""; + return { price: p, qty: q, wrap }; +} diff --git a/common_util/common_util_machine_symbol_name.ts b/common_util/common_util_machine_symbol_name.ts index 6c10f1e70..ae9d2a1fb 100644 --- a/common_util/common_util_machine_symbol_name.ts +++ b/common_util/common_util_machine_symbol_name.ts @@ -6,6 +6,7 @@ * 기계경비식 밖 문서 · 사용자가 손으로 바꾼 기호(사전에 없는 꼴)는 그대로 · 이름이 겹치면 그 기호만 그대로 * 화면 없음(시험이 Node 로 돌림) · 기계 제어 구역(836-1)도 `machineAlias` 로 같은 이름 * 인력 · 잡품 줄 여럿(842-3 — 덮음이 더한 `w_조종원N` · `P_잡품N`) = 줄마다 「인력N」 · 「잡품N」 + * 겹분수 중간 줄 이름(845-2 `partName`) — 산출근거가 손료 식을 「손료계수」 줄 + 「손료」 줄로 나눠 그림 * ========================================================================== */ import type { FormulaDoc } from "./common_util_formula_doc"; @@ -133,3 +134,7 @@ export function machineAlias(doc: FormulaDoc): Record { /** 기계 칸 이름(상각비계수 …) → 짧은 이름 · 없으면 그대로 — 기계 제어 구역(836-1) 값 칸 이름표 */ export const machineCellName = (col: string): string => CELL[col] ?? col; + +/** 겹분수 중간 줄 이름(845-2 · `splitFrac` ③) — 손료 식 분자 안 「(상각 + 정비) × (1 + 가산 ÷ 100) + 관리」 */ +const PART: Record = { C_손료: "손료계수" }; +export const partName = (id: string): string | undefined => PART[id]; diff --git a/resources/tester/helper_845_2_unit_show.cjs b/resources/tester/helper_845_2_unit_show.cjs new file mode 100644 index 000000000..38345a567 --- /dev/null +++ b/resources/tester/helper_845_2_unit_show.cjs @@ -0,0 +1,331 @@ +/* 845-2 단위 표시 지킴 헬퍼 — 산출근거(buildOut) · 왼쪽 편집(buildEdit)을 가짜 DOM 으로 차려 줄 글을 냄(화면 없음 · 진짜 코드) */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.resolve(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +class FakeNode { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.childNodes = []; + this.attributes = {}; + this.dataset = {}; + this.style = { setProperty: (k, v) => (this.style[k] = v) }; + this.className = ""; + const self = this; + this.classList = { + add: (...c) => c.forEach((x) => !self.cls().includes(x) && (self.className = `${self.className} ${x}`.trim())), + remove: (...c) => (self.className = self.cls().filter((x) => !c.includes(x)).join(" ")), + toggle: (c, on) => { + const has = self.cls().includes(c); + const want = on === undefined ? !has : !!on; + if (want && !has) self.classList.add(c); + if (!want && has) self.classList.remove(c); + return want; + }, + contains: (c) => self.cls().includes(c), + }; + } + cls() { + return String(this.className || "").split(/\s+/).filter(Boolean); + } + get children() { + return this.childNodes.filter((x) => x instanceof FakeNode); + } + get firstElementChild() { + return this.children[0] ?? null; + } + get firstChild() { + return this.childNodes[0] ?? null; + } + append(...xs) { + for (const x of xs) { + if (x === null || x === undefined) continue; + this.childNodes.push(x instanceof FakeNode ? x : new FakeText(String(x))); + } + } + appendChild(x) { + this.append(x); + return x; + } + prepend(...xs) { + const keep = this.childNodes; + this.childNodes = []; + this.append(...xs); + this.childNodes.push(...keep); + } + insertBefore(x) { + this.prepend(x); + return x; + } + replaceChildren(...xs) { + this.childNodes = []; + this.append(...xs); + } + remove() {} + set textContent(t) { + this.childNodes = t === "" || t === null || t === undefined ? [] : [new FakeText(String(t))]; + } + get textContent() { + return this.childNodes.map((c) => c.textContent).join(""); + } + set innerHTML(t) { + this.textContent = String(t).replace(/<[^>]*>/g, ""); + } + get innerHTML() { + return this.textContent; + } + setAttribute(k, v) { + this.attributes[k] = String(v); + } + getAttribute(k) { + return this.attributes[k] ?? null; + } + removeAttribute(k) { + delete this.attributes[k]; + } + hasAttribute(k) { + return k in this.attributes; + } + addEventListener() {} + removeEventListener() {} + dispatchEvent() { + return true; + } + focus() {} + blur() {} + click() {} + closest() { + return null; + } + contains() { + return false; + } + getBoundingClientRect() { + return { x: 0, y: 0, width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 }; + } + querySelector(s) { + return this.querySelectorAll(s)[0] ?? null; + } + querySelectorAll(s) { + const m = /^\.([\w-]+)$/.exec(s); + const tag = /^[a-z]+$/i.test(s) ? s.toUpperCase() : null; + const out = []; + const walk = (n) => { + for (const c of n.children) { + if ((m && c.cls().includes(m[1])) || (tag && c.tagName === tag)) out.push(c); + walk(c); + } + }; + walk(this); + return out; + } + cloneNode() { + return new FakeNode(this.tagName); + } +} +class FakeText { + constructor(t) { + this.textContent = t; + } +} + +function install() { + globalThis.Node = FakeNode; + globalThis.HTMLElement = FakeNode; + globalThis.Element = FakeNode; + globalThis.document = { + createElement: (t) => new FakeNode(t), + createElementNS: (_, t) => new FakeNode(t), + createTextNode: (t) => new FakeText(t), + createDocumentFragment: () => new FakeNode("fragment"), + body: new FakeNode("body"), + documentElement: new FakeNode("html"), + addEventListener() {}, + removeEventListener() {}, + querySelector: () => null, + querySelectorAll: () => [], + }; + globalThis.window = globalThis; + globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; + globalThis.sessionStorage = globalThis.localStorage; + globalThis.requestAnimationFrame = (f) => setTimeout(f, 0); + globalThis.matchMedia = () => ({ matches: false, addEventListener() {} }); + globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" }); + globalThis.fetch = () => Promise.reject(new Error("no fetch")); + globalThis.navigator ??= { language: "ko" }; +} + +/** 모듈 로더 — stubs[이름 끝] 가 있으면 그것 · css = 빈 것 · @ui/@config 길 */ +function makeLoader(stubs = {}) { + const cache = new Map(); + const resolve = (from, p) => { + if (p.startsWith("@ui/")) return path.join(ROOT, "ui_template", `${p.slice(4)}.ts`); + if (p.startsWith("@config/")) return path.join(ROOT, "config", `${p.slice(8)}.ts`); + if (p.startsWith(".")) { + const at = path.resolve(path.dirname(from), p); + return at.endsWith(".css") ? at : `${at}.ts`; + } + return null; + }; + 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) => { + if (p.endsWith(".css")) return {}; + const name = path.basename(p); + if (stubs[name]) return stubs[name]; + const r = resolve(at, p); + if (!r) { + if (stubs[p]) return stubs[p]; + return require(p); + } + return load(r); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; + } + return (rel) => load(path.join(ROOT, rel)); +} + +/** 요소 → 읽는 글(조판 = ⟦TeX⟧ · 입력 칸 = [값] · 단위 꼬리 = {단위}) */ +function textOf(n) { + if (n === null || n === undefined) return ""; + if (n instanceof FakeText) return n.textContent; + if (typeof n === "string") return n; + if (n.tex !== undefined) return `⟦${n.tex}⟧`; + if (n.tagName === "INPUT") return `[${n.value ?? ""}]`; + if (n.tagName === "SELECT") return `[${n.value ?? ""}▾]`; + return n.childNodes.map(textOf).join(""); +} + + +// ── 할일 ── stdin = {할일, 문서들 [{키, 문서, 연결}], 보기: [키]} · stdout = 할일 답 +install(); +const noop = () => undefined; +const load = makeLoader({ + katex: { render: (t, box) => (box.tex = t) }, + formula_doc_options: { machineText: () => null, optionText: (x) => x, liveOptions: (l) => l, nameMachines: noop }, + M02_DF_UnitPick: { + NO_UNIT: "(단위 없음)", + createUnitPick: (o) => Object.assign(new FakeNode("select"), { value: o.value }), + }, + M02_DF_Api: { cfMachineNames: async () => ({}), cfMachineOff: async () => [] }, + M02_MyMachine_Text: { mt: (k) => k }, + M02_MasterTemplete_Find_Pick: { openFindPicker: noop }, + line_stack_pick: { openStackPick: noop, openTablePick: noop }, + line_stack_machine: { openMachineWindow: noop }, + line_stack_material: { materialMark: () => null, matEntry: () => null, askFileWide: async () => undefined }, + line_stack_drag: { attachDrag: noop }, +}); +const out = load("A00_Common/line_stack_out.ts"); +const edit = load("A00_Common/line_stack_edit.ts"); +const opts = load("A00_Common/formula_doc_options.ts"); +const ls = load("common_util/common_util_line_stack.ts"); +const unit = load("common_util/common_util_line_stack_unit.ts"); +const cacheMod = load("common_util/common_util_formula_doc_cache.ts"); +const calc = load("common_util/common_util_formula_doc_calc.ts"); +const all = { has: () => true, add: noop, delete: noop }; + +/** \dfrac 겹 층 */ +function depth(s) { + const group = (i) => { + let d = 0; + for (let k = i; k < s.length; k++) { + if (s[k] === "{") d++; + else if (s[k] === "}" && --d === 0) return k; + } + return s.length - 1; + }; + let best = 0; + let i = 0; + while ((i = s.indexOf("\\dfrac{", i)) >= 0) { + const a1 = group(i + 6); + const b1 = group(a1 + 1); + best = Math.max(best, 1 + Math.max(depth(s.slice(i + 7, a1)), depth(s.slice(a1 + 2, b1)))); + i = b1 + 1; + } + return best; +} + +function right(c) { + const cache = cacheMod.createDocCache(); + cache.merge(c.연결 || {}); + const a = calc.calcDoc(c.문서, cache.links, {}); + const root = out.buildOut(c.문서, a, { cache, open: all, toggle: noop, miss: noop, redraw: noop }); + return root + .querySelectorAll("tr") + .filter((tr) => tr.cls().includes("lstk-orow")) + .map((tr) => ({ cls: tr.cls().join(" "), t: textOf(tr.children[0]) })); +} + +function left(c) { + const cache = cacheMod.createDocCache(); + cache.merge(c.연결 || {}); + const doc = c.문서; + const a = calc.calcDoc(doc, cache.links, {}); + const sub = (k) => { + try { + return cache.links.doc(k) ?? null; + } catch { + return null; + } + }; + const root = edit.buildEdit({ + doc, answer: a, items: ls.stackOf(doc), change: noop, table: async () => null, subDoc: sub, + name: () => "", open: new Set((doc.금액 || []).map((_, i) => `mctl:${i}`)), pair: noop, + machine: { scope: "x" }, calc: (d, g) => calc.calcDoc(d, cache.links, g, [], new Map(), {}), lists: () => null, + }); // prettier-ignore + const rows = []; + const walk = (n) => { + for (const ch of n.children) { + if (ch.cls().includes("lstk-frow")) rows.push({ sym: ch.dataset.sym ?? ch.dataset.mctlField ?? "", t: textOf(ch) }); + walk(ch); + } + }; + walk(root); + return [...opts.buildOptions(doc, {}, true, noop).map((x) => ({ sym: `opt:${x.dataset.option}`, t: textOf(x) })), ...rows]; +} + +/** 전수 지킴 — 줄 글에서 규칙 어긋남만 */ +const MONEY = /^(.+?) : (?!—)(.*\S)$/; +function guard(c, machine) { + const before = JSON.stringify(c.문서); + const rows = right(c); + const bad = []; + let sub = false; + for (const r of rows) { + if (r.cls.includes("subhead")) sub = machine.has(r.t.replace(/ \[[^\]]*\]$/, "")); + const texs = [...r.t.matchAll(/⟦([^⟧]*)⟧/g)].map((m) => m[1]); + if ((r.cls.includes("is-sub") ? sub : c.기계) && Math.max(0, ...texs.map(depth)) > 1) bad.push(["겹분수", r.t]); + // 머리 [결과 단위] = 문서 결과단위 글 그대로(자료 몫 — 「원(원문 단위 없음)」 등 설명 포함) + if (!/head/.test(r.cls) && /따름|원문 단위 없음|수\(단위/.test(r.t)) bad.push(["메타 단위", r.t]); + const m = MONEY.exec(r.t); + if (m && !/^\d+\. /.test(r.t) && !/(원|원\/\S+)$/.test(m[2]) && /\d$/.test(m[2])) bad.push(["금액 원 없음", r.t]); + const q = / × ⟦([^⟧]*)⟧ (\S+) = /.exec(r.t); + if (q && /\\times|\\div|\+/.test(q[1]) && !q[1].startsWith("\\left(")) bad.push(["수량 식 괄호 없음", r.t]); + } + return { bad, 같음: JSON.stringify(c.문서) === before }; +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const show = new Set(input.보기 || []); +const machineNames = new Set(input.기계이름 || []); +let res; +if (input.할일 === "전수") { + res = input.문서들.map((c) => { + const g = guard(c, machineNames); + return { 키: c.키, ...g, ...(show.has(c.키) ? { 오른쪽: right(c), 왼쪽: left(c) } : {}) }; + }); +} else if (input.할일 === "단위") { + res = { + 글: input.글.map((u) => unit.unitText(u)), + 변수: input.변수.map((v) => unit.varUnit(v)), + 금액: input.금액.map(([line, price, qty, v, s]) => unit.moneyUnits(line, price, qty, v, s)), + }; +} +process.stdout.write(JSON.stringify(res)); diff --git a/resources/tester/test_845_2_unit_show.py b/resources/tester/test_845_2_unit_show.py new file mode 100644 index 000000000..3c5615316 --- /dev/null +++ b/resources/tester/test_845_2_unit_show.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""PLAN 845-2(요구 879 · 880) — 단가산출 기초 단위 표시 규칙 한 벌 · 기계 산출근거 겹분수 행 나눔(그림만). + +① 전수 1,520 — 산출근거(펼침 모두)를 진짜 코드로 차린 줄 글: + 금액 · 소계 · 합계 = 「원」 · 수량이 식이면 괄호로 식 전체에 단위(「÷ 560 시간」 꼴 없음) · + 메타 단위(「… 따름」 · 「수(…)」) 글 0 · 기계경비식(맨 위 · 펼침) 분수 한 층 · 문서 자료 안 바뀜 +② GC000088 — 사용자 그림 자리: 「59,020 원/시간 × (1 × 8 ÷ 560) 시간 = 843.1 원」 · 펼친 굴착기 = 상여 · 휴지 · 손료계수 중간 줄 +③ 왼쪽 — 기계 제어 구역 값 칸 단위 = 오른쪽 펼침과 같은 글(천원 · 10⁻⁷/시간 · ℓ/hr) · 단위는 칸 밖 바로 뒤 +④ 단위 규칙 낱개 — 메타 · 설명 괄호 · 10⁻⁷ · 기계 칸 연결 · 금액 줄 자리 +""" + +from __future__ import annotations + +import importlib +import json +import os +import subprocess + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 (scripts 길) +from M02_MasterTemplete import M02_Doc_Copy as dc + +mr = importlib.import_module("master_doc_record") +HERE = os.path.dirname(os.path.abspath(__file__)) +MACHINE = "기계경비식" + + +def _js(payload: dict): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_845_2_unit_show.cjs")], + input=json.dumps(dc.ml._norm(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr[-3000:] + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def world(): + docs = dc.folder() + links = dc.ml.Links(dc.skc.whole_(), docs) + cases = [] + for key, doc in sorted(docs.items()): + rec = mr.Rec(links) + rec.prefetch(doc) + dc.mc.calc(doc, rec) + cases.append( + { + "키": key, + "문서": doc, + "연결": rec.out(), + "기계": (doc.get("부품") or {}).get("갈래") == MACHINE, + } + ) + names = [c["문서"].get("이름") for c in cases if c["기계"]] + got = _js({"할일": "전수", "문서들": cases, "보기": ["GC000088"], "기계이름": names}) + return {g["키"]: g for g in got} + + +def test_전수_규칙_어긋남_0(world): + assert len(world) >= 1513 + bad = {k: g["bad"][:2] for k, g in world.items() if g["bad"]} + assert bad == {} + assert all(g["같음"] for g in world.values()) # 그리기만 — 문서 자료 안 바뀜 + + +def test_GC000088_비목_줄_단위_자리(world): + rows = [r["t"] for r in world["GC000088"]["오른쪽"]] + labor = next(t for t in rows if t.startswith("노무비 : ") and "×" in t) + assert labor.startswith("노무비 : 59,020 원/시간 × ⟦\\left(1 \\times 8 \\div 560\\right)⟧ 시간 = ") + assert labor.endswith(" 원") + assert any(t.startswith("소 계 : ") and t.endswith(" 원") for t in rows) + assert any(t.startswith("계 : ") and t.endswith(" 원") for t in rows) + # 펼친 굴착기 시간당 경비 — 겹분수 대신 중간 줄 + sub = "\n".join(rows) + assert "⟦\\text{상여}⟧ = ⟦\\dfrac{\\text{상여분자}}{\\text{상여분모}}⟧ = ⟦\\dfrac{16}{12}⟧" in sub + assert "⟦\\text{휴지}⟧ = ⟦\\dfrac{\\text{휴지분자}}{\\text{휴지분모}}⟧ = ⟦\\dfrac{25}{20}⟧" in sub + assert "\\dfrac{1}{8} \\times 1.333333 \\times 1.25⟧ = ⟦0.208333" in sub + assert "⟦\\text{손료계수}⟧ = " in sub and "= ⟦2{,}085\\ \\text{10⁻⁷/시간}⟧" in sub + assert "\\dfrac{142{,}069 \\times 1{,}000 \\times 2{,}085}{10{,}000{,}000}⟧ = ⟦29{,}621" in sub + assert "⟦\\text{가격}⟧ = 142,069 천원" in sub + assert "⟦\\text{직종}⟧ = 1048" in sub # 코드 값 = 쉼표 없음 + assert "조종원 : ⟦283{,}297⟧ 원/인 × ⟦\\left(1 \\times 0.208333\\right)⟧ 인 = 59,020.2 원" in sub + + +def test_왼쪽_제어_구역_단위_오른쪽과_같은_글(world): + rows = {r["sym"]: r["t"] for r in world["GC000088"]["왼쪽"]} + assert rows["가격"] == "가격=[142069]천원" + assert rows["상각비계수"] == "상각=[900]10⁻⁷/시간" + assert rows["주연료"] == "주연료=[19.5]ℓ/hr" + assert rows["Q_시공^표"].startswith("Q_시공2=[560]㎥") + + +def test_단위_규칙_낱개(): + got = _js( + { + "할일": "단위", + "글": ["구분 따름", "줄 따름", "수(단위는 줄의 단위 칸 따름)", "수", "원문 단위 없음", + "%(주연료비의)", "10^-7/시간", "㎥/hr", "인", ""], # fmt: skip + "변수": [ + {"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}}, + {"단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율"}}, + {"단위": "%", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}}, + {"단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}"}}, + ], + "금액": [ + [{"단위": "시간", "수량": "Q × 8 ÷ Q2"}, ["var", "P"], ["bin", "/", ["var", "a"], ["var", "b"]], {"단위": ""}, {"결과단위": "원/시간"}], # fmt: skip + [{"단위": "인", "수량": "Q"}, ["var", "w"], ["var", "Q"], {"단위": ""}, None], + [{"단위": "식"}, ["bin", "*", ["var", "a"], ["var", "b"]], None, None, None], + ], + } + ) + assert got["글"] == ["", "", "", "", "", "%", "10⁻⁷/시간", "㎥/hr", "인", ""] + assert got["변수"] == ["천원", "%", "%", ""] + assert got["금액"] == [ + {"price": "원/시간", "qty": "시간", "wrap": True}, + {"price": "원/인", "qty": "인", "wrap": False}, + {"price": "", "qty": "", "wrap": False}, + ]