feat(714-2): 공용 단가산출 계산서 틀 — A00_Common/calc_sheet.ts(704-2 계산서 줄 올림 · 묶음 머리 변수 칸 · 바꾸면 onInput) · M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌로 씀(장비 한 줄 · 기준단위 · 환산 근거는 714-1 뒤)

- 704-2 Labor_Sheet 줄 · 소계 · 계 = calcSheetRows(표본 셋 DOM 전후 같음)
- 새 M01_MasterData_UI_LogicLab_Est.ts — /text 묶음 그대로 · 묶음이 쓰는 시험 입력(714-3 usedInputs) = 머리 변수 칸 · 「준비 중」 자리 걷음
- 낡은 시험 test_704_logic_standard 고침 — 계산서가 계산 줄 부품을 공용 계산서 안에서 씀(import 자리만 바뀜)
- 새 지킴 시험 test_714_2_calc_sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 16:03:36 +09:00
co-authored by Claude Opus 5.5
parent 915feb436e
commit cddb28b63a
8 changed files with 224 additions and 27 deletions
+106
View File
@@ -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<string, string>;
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;
}
@@ -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")]),
@@ -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<string, string>;
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;
}
@@ -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;
}
@@ -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"],
@@ -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.출처);
+2 -1
View File
@@ -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
+47
View File
@@ -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 "<button" not in s and "document.createElement" not in s and "<table" not in s
assert "@ui/ui_template_calc_row" in s and "@ui/ui_template_input" in s
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_식_글():
s = _text(SHEET)
pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")]
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", pure + "\n" + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]