diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts new file mode 100644 index 000000000..b8422bdc3 --- /dev/null +++ b/A00_Common/calc_sheet.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * calc_sheet.ts + * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것. + * 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거 + * 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 + * 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 + * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음 + * ========================================================================== */ + +import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createText } from "@ui/ui_template_text"; + +export interface SheetLine { + 이름: string; + /** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */ + 글: string; + 금액: number | null; +} +export interface SheetGroup { + 비목: string; + 소계: number | null; + 끝수?: string; + 줄: SheetLine[]; +} +export interface SheetInput { + 이름: string; + 단위?: string; + 고르기?: (string | number)[]; +} +export interface SheetOptions { + 묶음: SheetGroup[]; + 계: number | null; + /** 맨 아래 계 줄 이름(「계 (원/㎡)」) */ + sumLabel: string; + /** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */ + inputs?: SheetInput[]; + values?: Record; + uses?: (group: SheetGroup, index: number) => string[]; + onInput?: (name: string, value: string) => void; +} + +const won = (v: number | null, digits = 0): string => + v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const out = (v: number | null, key = false, digits = 0): HTMLElement => + el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); + +/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ +export const sheetFormula = (text: string): string => + text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + +/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ +function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { + let control: HTMLElement; + if (spec.고르기?.length) { + control = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value, + compact: true, + ariaLabel: spec.이름, + onChange: onInput, + }).root; + } else { + const box = createInputField({ value, compact: true }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("change", () => { + if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim()); + }); + control = box.root; + } + const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control); + row.dataset.sheetVar = spec.이름; + return row; +} + +/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ +export function calcSheetRows(o: SheetOptions): HTMLElement[] { + const rows: HTMLElement[] = []; + const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s])); + o.묶음.forEach((g, gi) => { + const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n)); + if (vars.length && o.onInput) { + const onInput = o.onInput; + const head = createText(g.비목, "muted", 14); + head.dataset.sheetHead = g.비목; + rows.push(head); + for (const n of vars) + rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); + } + for (const line of g.줄) + rows.push( + createCalcFormulaRow( + line.이름, + createCalcFormula(sheetFormula(line.글)), + out(line.금액, false, 1), + ), + ); + rows.push( + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), + ); + }); + rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); + return rows; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ed14e9e49..bedd8c879 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -24,6 +24,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; +import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; import { condText, openMaterialModal, @@ -38,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; import { createBadge } from "@ui/ui_template_badge"; -import { createEmpty } from "@ui/ui_template_empty"; import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; /** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ @@ -525,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { host.replaceChildren( ...infoBox(ctx), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), - block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"), + block( + section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]), + "est", + ), ...(ctx.flowHost ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] : [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts new file mode 100644 index 000000000..adef8655f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Est.ts + * 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로. + * 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜) + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; +import type { LogicInput } from "./M01_MasterData_UI_Logic_Api"; +import { safeReason } from "./M01_MasterData_UI_Logic_Text"; +import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +export interface EstContext { + text: PreviewTextAnswer | null; + unit: string; + inputs: LogicInput[]; + values: Record; + onInput?: (name: string, value: string) => void; +} + +export function buildEst(ctx: EstContext): HTMLElement { + const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } }); + const text = ctx.text; + if (!text || (text.ok && text.계산됨 === false)) { + host.append(createText(tl("Formula_Slot"), "muted")); + return host; + } + if (!text.ok) { + host.append(createText(safeReason(text.reason), "error")); + return host; + } + const names = ctx.inputs.map((x) => x.이름); + host.append( + ...calcSheetRows({ + 묶음: text.groups, + 계: text.계, + sumLabel: `${tl("Total")} (${ctx.unit || "원"})`, + inputs: ctx.inputs, + values: ctx.values, + onInput: ctx.onInput, + uses: (_, gi) => [ + ...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))), + ], + }), + ); + return host; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 0292d5cd7..a1fbc0bd6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -225,3 +225,11 @@ max-height: 40vh; overflow: auto; } + +/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */ +.m01lab-est { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 52b4329de..aea6cd7b6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -30,10 +30,6 @@ const TEXT = { Calc_Title: ["시험 계산", "Trial calculation"], View_Est: ["인력·재료·경비", "Labor·material·expense"], View_Label: ["보기", "View"], - View_Soon: [ - "준비 중 — 인력 · 재료 · 경비 묶음 보기", - "Coming soon — labor · material · expense view", - ], Now_Inputs: ["지금 입력", "Current inputs"], Now_None: ["없음", "none"], G_labor: ["인력", "Labor"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts index 1c22a7caa..d21620c12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts @@ -2,23 +2,17 @@ * M02_MasterTemplete_Labor_Sheet.ts * 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다 * 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 · - * [담기] = 부르는 쪽 담기 동작 그대로(`put`). + * [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -const won = (v: number | null, digits = 0): string => - v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); - -const out = (v: number | null, key = false, digits = 0): HTMLElement => - el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); - export function openStandardSheet(row: StandardRow, put: () => void): void { const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } }); sheet.append(createText(tc("Loading"))); @@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void { createText(head, "muted", 14), createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"), ]; - for (const g of got.묶음) { - for (const line of g.줄) { - const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); - lines.push( - createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)), - ); - } - lines.push( - createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), - ); - } lines.push( - createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)), + ...calcSheetRows({ + 묶음: got.묶음, + 계: got.계, + sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`, + }), ); sheet.replaceChildren(...lines); source.show(got.출처); diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index bfd9f02b8..133ed7766 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None: assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick assert "(!work.select.value || x.공종 === work.select.value)" in pick sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8") - assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet + # 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀) + assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet for name in ("Labor_Pick", "Labor_Sheet"): text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8") assert len(text.splitlines()) <= 700 diff --git a/resources/tester/test_714_2_calc_sheet.py b/resources/tester/test_714_2_calc_sheet.py new file mode 100644 index 000000000..b2c8772d5 --- /dev/null +++ b/resources/tester/test_714_2_calc_sheet.py @@ -0,0 +1,47 @@ +"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음). + +- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀 +- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버) +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SHEET = ROOT / "A00_Common" / "calc_sheet.ts" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def test_한_벌을_씀(): + m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts") + est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts") + detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts") + assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02 + assert "calcSheetRows(" in est and "buildEst(" in detail + + +def test_부품_상자만(): + s = _text(SHEET) + assert "