From b472f200b6479fa36c7e5be53c2368054ba494ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:18:11 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(751-2):=20=EC=9B=90=ED=99=94=20?= =?UTF-8?q?=EA=BC=B4=20=EA=B3=B5=EC=9A=A9=20=ED=95=9C=20=EB=B2=8C=20?= =?UTF-8?q?=E2=80=94=20common=5Futil=5Fmoney.ts=20=C2=B7=20master=5Ffmt.py?= =?UTF-8?q?=20=EC=A7=9D=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatMoney(정수) · formatLineMoney(소수 1 · 끝 0 없음) · formatPrice(단가 소수 3 · 200 미만 유효숫자 넷) · formatNumber(수량 · 율 유효숫자 넷) - M01 Logic_Money · Logic_Edit 는 다시 내보내기만(부르는 쪽 그대로) - 파이썬 짝 master_fmt.py(절반 올림 · 은행가 반올림 어긋남 해소) · master_text 갈아 끼움은 받기 뒤 Co-Authored-By: Claude Sonnet 5.5 --- .../M01_MasterData_UI_Logic_Edit.ts | 7 +- .../M01_MasterData_UI_Logic_Money.ts | 22 ++----- common_util/common_util_money.ts | 46 +++++++++++++ resources/master_data/scripts/master_fmt.py | 65 ++++++++++++++++++ resources/tester/helper_588_2_money_fmt.cjs | 21 ++++++ resources/tester/test_588_2_money_fmt.py | 66 +++++++++++++++++++ 6 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 common_util/common_util_money.ts create mode 100644 resources/master_data/scripts/master_fmt.py create mode 100644 resources/tester/helper_588_2_money_fmt.cjs create mode 100644 resources/tester/test_588_2_money_fmt.py diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts index 0421855ed..d706e0d10 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Edit.ts @@ -20,6 +20,7 @@ import { tx } from "./M01_MasterData_UI_Logic_Text"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import { createIconButton } from "@ui/ui_template_button"; +import { formatNumber } from "../common_util/common_util_money"; export type PickMode = "element" | "table"; export type PickDone = (item: ElementBrief, column?: string) => void; @@ -53,11 +54,7 @@ const groupOf = (key: string): string => const COSTS = ["노무비", "재료비", "경비"]; const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; -export function formatNumber(value: unknown): string { - if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); - if (value === null || value === undefined) return ""; - return typeof value === "object" ? JSON.stringify(value) : String(value); -} +export { formatNumber }; function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement { const input = el("input", { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Money.ts b/M01_MasterData/M01_MasterData_UI_Logic_Money.ts index f89b7475d..a5e53dc8f 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Money.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Money.ts @@ -1,19 +1,11 @@ /* ============================================================================= * M01_MasterData_UI_Logic_Money.ts - * 돈 모양 한 벌 — 원 단위 정수(반올림). 단가·금액·소계·계 자리만 씀 · 수량·비율은 `formatNumber` 그대로 + * 돈 모양 — 공용 꼴 한 벌(`common_util/common_util_money.ts`)을 다시 내보냄. 부르는 쪽은 그대로. * ========================================================================== */ -import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; - -export const formatMoney = (value: unknown): string => - typeof value === "number" - ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) - : formatNumber(value); - -/** 단가 모양 — 정수로 보이되 200 원 미만은 반올림 오차가 커서 소수 넷째 자리·유효숫자 4 자리 중 많은 쪽(뒤 0 뗌) */ -export const formatPrice = (value: unknown): string => - typeof value === "number" && value !== 0 && Math.abs(value) < 200 - ? value.toLocaleString("ko-KR", { - maximumFractionDigits: Math.max(4, 3 - Math.floor(Math.log10(Math.abs(value)))), - }) - : formatMoney(value); +export { + formatLineMoney, + formatMoney, + formatNumber, + formatPrice, +} from "../common_util/common_util_money"; diff --git a/common_util/common_util_money.ts b/common_util/common_util_money.ts new file mode 100644 index 000000000..abbf4fa2c --- /dev/null +++ b/common_util/common_util_money.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * common_util_money.ts + * 화면 숫자 글 꼴 한 벌 — 금액 · 단가 · 수량 · 율 · 빈 값 · 음수. 셈 값은 그대로, 보이는 글만 만듦(반올림). + * formatMoney 원 단위 정수 (계 · 소계 · 합계 · 내역 금액) + * formatLineMoney 최대 소수 1 · 끝 0 뗌 (일위대가 줄 금액 · 산근 근거 줄) + * formatPrice 단가 — 200 원 미만 = 유효숫자 넷 · 그 밖 = 최대 소수 3 · 끝 0 뗌 + * formatNumber 수량 · 율 · 품 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 · 끝 0 뗌 + * 값이 숫자가 아니면 빈 값 = 「」 · 객체 = JSON 글 · 그 밖 = String. + * 짝: `resources/master_data/scripts/master_fmt.py` (거울 시험 `resources/tester/test_588_2_money_fmt.py`). + * ========================================================================== */ + +const LOCALE = "ko-KR"; + +function fixed(value: number, maxDigits: number): string { + const text = value.toLocaleString(LOCALE, { maximumFractionDigits: Math.min(20, maxDigits) }); + return text === "-0" ? "0" : text; +} + +/** 수량 꼴 자리 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 (0.00167 → 0.00167) */ +function numberPlaces(value: number): number { + return value === 0 || !Number.isFinite(value) + ? 4 + : Math.max(4, 3 - Math.floor(Math.log10(Math.abs(value)))); +} + +function other(value: unknown): string { + if (value === null || value === undefined) return ""; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +export function formatNumber(value: unknown): string { + return typeof value === "number" ? fixed(value, numberPlaces(value)) : other(value); +} + +export function formatMoney(value: unknown): string { + return typeof value === "number" ? fixed(value, 0) : other(value); +} + +export function formatLineMoney(value: unknown): string { + return typeof value === "number" ? fixed(value, 1) : other(value); +} + +export function formatPrice(value: unknown): string { + if (typeof value !== "number") return other(value); + return Math.abs(value) < 200 ? formatNumber(value) : fixed(value, 3); +} diff --git a/resources/master_data/scripts/master_fmt.py b/resources/master_data/scripts/master_fmt.py new file mode 100644 index 000000000..05c933c9a --- /dev/null +++ b/resources/master_data/scripts/master_fmt.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""숫자 글 꼴 한 벌 — 금액 · 단가 · 수량 · 율 · 빈 값 · 음수. 셈 값은 그대로, 보이는 글만 만듦(반올림 = 절반 올림). + +TS 짝: `common_util/common_util_money.ts` — 두 파일은 같은 글을 냄(거울 시험 `test_588_2_money_fmt.py`). + fmt_money 원 단위 정수 (계 · 소계 · 합계 · 내역 금액) + fmt_line_money 최대 소수 1 · 끝 0 뗌 (일위대가 줄 금액 · 산근 근거 줄) + fmt_price 단가 — 200 원 미만 = 유효숫자 넷 · 그 밖 = 최대 소수 3 · 끝 0 뗌 + fmt 수량 · 율 · 품 — 소수 넷째 자리 · 유효숫자 넷 중 많은 쪽 · 끝 0 뗌 +값이 숫자가 아니면 빈 값 = 「」 · 그 밖 = str. +""" + +from __future__ import annotations + +from decimal import ROUND_HALF_UP, Decimal, localcontext + + +def _num(value) -> Decimal | None: + """숫자 → Decimal (불리언 · NaN · 무한대 · 숫자 아님 = None).""" + if isinstance(value, bool): + return None + if isinstance(value, float): + value = repr(value) # 짧은 글 → 자바스크립트 숫자와 같은 자리 + elif not isinstance(value, (Decimal, int)): + return None + out = Decimal(value) + return out if out.is_finite() else None + + +def _other(value) -> str: + return "" if value is None else str(value) + + +def _fixed(value: Decimal, places: int) -> str: + """소수 `places` 자리까지 절반 올림 · 끝 0 뗌 · 천 단위 쉼표.""" + with localcontext() as ctx: + ctx.prec = 80 + cut = value.quantize(Decimal(1).scaleb(-min(places, 20)), rounding=ROUND_HALF_UP) + sign, digits = ("-", -cut) if cut < 0 else ("", cut) + whole, _, rest = f"{digits:f}".partition(".") + rest = rest.rstrip("0") + return sign + f"{int(whole):,}" + (f".{rest}" if rest else "") + + +def fmt(value) -> str: + d = _num(value) + if d is None: + return _other(value) + return _fixed(d, 4 if not d else max(4, 3 - d.adjusted())) + + +def fmt_money(value) -> str: + d = _num(value) + return _other(value) if d is None else _fixed(d, 0) + + +def fmt_line_money(value) -> str: + d = _num(value) + return _other(value) if d is None else _fixed(d, 1) + + +def fmt_price(value) -> str: + d = _num(value) + if d is None: + return _other(value) + return fmt(d) if abs(d) < 200 else _fixed(d, 3) diff --git a/resources/tester/helper_588_2_money_fmt.cjs b/resources/tester/helper_588_2_money_fmt.cjs new file mode 100644 index 000000000..c3fa7aa89 --- /dev/null +++ b/resources/tester/helper_588_2_money_fmt.cjs @@ -0,0 +1,21 @@ +/* 원화 꼴 한 벌(`common_util_money.ts`) 거울 헬퍼 — 트랜스파일해 Node 로 돌림 · + * stdin = [값 글 | null ...] (숫자 글은 Number 로) · stdout = {함수: [글 ...]} */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "common_util", "common_util_money.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); + +const cases = JSON.parse(fs.readFileSync(0, "utf8")).map((c) => + c !== null && c.num ? Number(c.num) : c === null ? null : c.text, +); +const out = {}; +for (const name of ["formatNumber", "formatMoney", "formatLineMoney", "formatPrice"]) { + out[name] = cases.map((c) => box.exports[name](c)); +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_588_2_money_fmt.py b/resources/tester/test_588_2_money_fmt.py new file mode 100644 index 000000000..2a26b5749 --- /dev/null +++ b/resources/tester/test_588_2_money_fmt.py @@ -0,0 +1,66 @@ +"""원화 꼴 한 벌 거울 시험 — `master_fmt.py`(파이썬) ↔ `common_util_money.ts`(TS · Node) 가 같은 글. + +CLAUDE.md 5장 ① 짝. 값은 숫자 글(`num`) · 숫자 아닌 글(`text`) · 빈 값(None). +""" + +import json +import os +import subprocess +import sys +from decimal import Decimal + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "master_data", "scripts")) + +import master_fmt as mf # noqa: E402 + +NUMS = [ + "0", "1", "2.5", "-1234.5", "1.00005", "0.12345", "0.00167", "22.9", "1846.39", + "124419.853", "275790", "69643.9394", "0.2525", "4", "199.99", "200", "1234567.891", + "-0.00001", "0.05", "870.2455", "59020", "1414.9", "-22.96", +] # fmt: skip +CASES = [{"num": n} for n in NUMS] + [{"text": "가나다"}, {"text": ""}, None] +PAIRS = { + "formatNumber": mf.fmt, + "formatMoney": mf.fmt_money, + "formatLineMoney": mf.fmt_line_money, + "formatPrice": mf.fmt_price, +} + + +def _ts() -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_588_2_money_fmt.cjs")], + input=json.dumps(CASES, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _py_value(case): + if case is None: + return None + return Decimal(case["num"]) if "num" in case else case["text"] + + +def test_python_ts_same_text(): + ts_out = _ts() + for name, py_fn in PAIRS.items(): + for case, ts_text in zip(CASES, ts_out[name]): + assert py_fn(_py_value(case)) == ts_text, (name, case) + + +def test_known_shapes(): + assert mf.fmt(Decimal("1.00005")) == "1.0001" + assert mf.fmt(Decimal("0.00167")) == "0.00167" + assert mf.fmt(Decimal("4")) == "4" # 율 — 「4 %」 (「4.0」 아님) + assert mf.fmt_money(Decimal("124419.853")) == "124,420" + assert mf.fmt_line_money(Decimal("69643.9394")) == "69,643.9" + assert mf.fmt_line_money(Decimal("18064")) == "18,064" # 「.0」 꼬리 없음 + assert mf.fmt_price(Decimal("1846.39")) == "1,846.39" # 정수로 숨기지 않음 + assert mf.fmt_price(Decimal("22.9")) == "22.9" + assert mf.fmt_money(Decimal("-0.4")) == "0" + assert mf.fmt(None) == "" and mf.fmt("가") == "가" From b58b01a007f1c1ea4209660afc0c034614a9074d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:20:09 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(748-3):=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=88=98=EC=8B=9D=20=EB=B0=94=EB=8B=A5=20=EB=85=B8=EB=AC=B4?= =?UTF-8?q?=EB=B9=84=20=C2=B7=20=EC=9E=AC=EB=A3=8C=EB=B9=84=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EB=B9=84=20=EA=B3=A0=EC=A0=95=20=EC=A4=84(=EA=B3=84?= =?UTF-8?q?=20=EC=97=86=EC=9D=8C)=20=C2=B7=20=EB=B9=84=EB=AA=A9=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=EC=82=AC=EC=9D=B4=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=3D=20=EB=B0=94=EB=8B=A5=20=EC=A4=84=20=EC=9C=84=20=EC=97=AC?= =?UTF-8?q?=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `LogicLab_Foot` — 바닥 비목 소계 줄 한 부품(단가산출 상세 4 칸 · 텍스트 수식 3 칸 계 칸 없음) · 상자 소계는 그대로 - 비목 상자 사이 간격 = 바닥 줄 위 여유(공통 값 `--m01lab-box-gap` 12px · 두 보기) - 줄마다 붙던 옵션 값 칩 걷음은 748-2 커밋에 함께(위 옵션 칸이 대신) - 낡은 시험 고침: test_737 바닥 줄 부품 · 칸 수 · 상자 간격 대조 Co-Authored-By: Claude Sonnet 5.5 --- .../M01_MasterData_UI_LogicLab_Foot.ts | 59 +++++++++++++++++++ .../M01_MasterData_UI_LogicLab_Formula.ts | 14 ++--- .../M01_MasterData_UI_LogicLab_Ho.ts | 34 +++-------- .../M01_MasterData_UI_LogicLab_Style.css | 16 ++++- resources/tester/test_737_option_grid_foot.py | 5 +- 5 files changed, 92 insertions(+), 36 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Foot.ts diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Foot.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Foot.ts new file mode 100644 index 000000000..75e6f8605 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Foot.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Foot.ts + * 화면(모달) 바닥에 붙는 비목 소계 줄(737-1 · PLAN 748-3) — 단가산출 상세(노무비 · 재료비 · 경비 · 계 네 칸) · + * 텍스트 수식(노무비 · 재료비 · 경비 세 칸 · 계 칸 없음 — 중복 표기) 두 보기가 같이 씀. + * 값 = 서버 셈 그대로(여기서 다시 셈 안 함) · 계산 전 「—」. + * ========================================================================== */ + +import { createCalcFormulaRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +export type FootKey = "labor" | "material" | "cost"; +const LABEL: Record = { labor: "노무비", material: "재료비", cost: "경비" }; +export const FOOT_KEYS = Object.keys(LABEL) as FootKey[]; +/** 서버 비목 이름 → 칸 */ +export const FOOT_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "cost", +}; + +/** 비목 소계 줄 — `total` 을 주면 맨 끝 「계」 칸까지 네 칸(주지 않으면 세 칸) */ +export function buildFoot( + sums: Record, + calculated: boolean, + total?: number, +): HTMLElement { + const cells = FOOT_KEYS.map((k) => { + const out = el("span", { + className: "ui-calc-row__out is-key", + text: calculated ? formatMoney(sums[k]) : "—", + }); + const row = createCalcFormulaRow(LABEL[k], null, out); + row.dataset.group = k; + row.dataset.subtotal = calculated ? String(sums[k]) : ""; + return row; + }); + const foot = el("div", { + className: "m01lab__foot", + children: [ + el("div", { className: "m01lab__sums", children: cells }), + ...(total === undefined + ? [] + : [ + el("p", { + className: "m01lab__total", + attrs: { "data-sum": calculated ? String(total) : "" }, + children: [ + el("span", { text: `${tl("Total")} ` }), + el("span", { text: calculated ? formatMoney(total) : "—" }), + ], + }), + ]), + ], + }); + if (total === undefined) foot.dataset.cols = "3"; + return foot; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 99523db45..ae4bf7c60 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -15,6 +15,7 @@ import { optionGrid, varRow } from "../A00_Common/calc_sheet"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; +import { buildFoot, FOOT_KEYS, FOOT_OF, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot"; import { nameTokens, tokenRow, valueTokens } from "./M01_MasterData_UI_LogicLab_FormulaTokens"; import "./M01_MasterData_UI_LogicLab_Formula.css"; @@ -223,12 +224,9 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { card.dataset.fxBox = g.비목; return card; }); - host.replaceChildren( - ...boxes, - el("p", { - className: "m01lab__total", - attrs: { "data-text-sum": String(text.계) }, - text: `${tl("Total")} ${formatMoney(text.계)}`, - }), - ); + // 748-3 맨 아래 비목 소계 고정 줄(노무비 · 재료비 · 경비 — 계 칸 없음 · 상자 소계는 그대로) + const sums = Object.fromEntries( + FOOT_KEYS.map((k) => [k, text.groups.find((g) => FOOT_OF[g.비목] === k)?.소계 ?? 0]), + ) as Record; + host.replaceChildren(...boxes, buildFoot(sums, calculated)); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 754d8e28c..9e2c32258 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -12,7 +12,6 @@ * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; -import { createCalcFormulaRow } from "@ui/ui_template_calc_row"; import { createCard } from "@ui/ui_template_card"; import { el } from "@ui/ui_template_elements"; import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -44,6 +43,7 @@ import { openTableModal, type PickCond, } from "./M01_MasterData_UI_LogicLab_Modal"; +import { buildFoot, FOOT_KEYS, type FootKey } from "./M01_MasterData_UI_LogicLab_Foot"; import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -653,35 +653,17 @@ export function buildHo(ctx: HoContext): HTMLElement[] { const boxes = boxesOf(all, ["인력", "재료", "경비"]); const used = new Set(boxes.flatMap(usesOf)); const common = (ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)); - const sums = (["labor", "material", "cost"] as Group[]).map((g) => { - const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0); - const out = el("span", { - className: "ui-calc-row__out is-key", - text: calculated ? formatMoney(v) : "—", - }); - const row = createCalcFormulaRow(COST_OF[g], null, out); - row.dataset.group = g; - row.dataset.subtotal = calculated ? String(v) : ""; - return row; - }); + const sums = Object.fromEntries( + FOOT_KEYS.map((g) => [ + g, + all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0), + ]), + ) as Record; const total = all.reduce((s, e) => s + (e.amount ?? 0), 0); return [ ...(boxes.length || !common.length ? [] : [commonCard(optionRows(common, ctx))]), ...boxes.map((b) => boxCard(b, ctx, calculated, common)), // 737-1 맨 아래 한 줄 4 칸(노무비 · 재료비 · 경비 · 계) — 화면(모달) 바닥에 붙음 - el("div", { - className: "m01lab__foot", - children: [ - el("div", { className: "m01lab__sums", children: sums }), - el("p", { - className: "m01lab__total", - attrs: { "data-sum": calculated ? String(total) : "" }, - children: [ - el("span", { text: `${tl("Total")} ` }), - el("span", { text: calculated ? formatMoney(total) : "—" }), - ], - }), - ], - }), + buildFoot(sums, calculated, total), ]; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 43d0a8ac4..e1902999b 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -256,7 +256,7 @@ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-8); - margin-top: var(--spacing-12); + margin-top: var(--m01lab-box-gap, var(--spacing-12)); padding: var(--spacing-8) var(--spacing-12); border: 1px solid var(--color-border); border-radius: var(--radius-lg); @@ -272,6 +272,20 @@ display: contents; } +/* 748-3 텍스트 수식 바닥 줄 = 세 칸(계 칸 없음) */ +.m01lab__foot[data-cols="3"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +/* 748-3 비목 상자 사이 간격 = 바닥 고정 줄 위 여유(같은 값 · 두 보기) */ +.m01-logic__editor { + --m01lab-box-gap: var(--spacing-12); +} + +.m01lab__ho-box + .m01lab__ho-box { + margin-top: var(--m01lab-box-gap, var(--spacing-12)); +} + .m01lab__foot .ui-calc-row { display: flex; flex-direction: column; diff --git a/resources/tester/test_737_option_grid_foot.py b/resources/tester/test_737_option_grid_foot.py index 66a2ba68e..e04d13368 100644 --- a/resources/tester/test_737_option_grid_foot.py +++ b/resources/tester/test_737_option_grid_foot.py @@ -27,7 +27,10 @@ def test_합계_한_줄_바닥_고정(): assert "position: sticky" in foot and "bottom: 0" in foot assert "repeat(4, minmax(0, 1fr))" in foot assert "overflow: clip" in STYLE # 메인 칸이 굴림 칸이면 바닥에 안 붙음 - assert 'className: "m01lab__foot"' in HO + foot_ts = (M01 / "M01_MasterData_UI_LogicLab_Foot.ts").read_text(encoding="utf-8") + assert 'className: "m01lab__foot"' in foot_ts # 748-3 두 보기가 같은 바닥 줄 부품 + assert "buildFoot(sums, calculated, total)" in HO and "buildFoot(sums, calculated)" in FX + assert 'data-cols="3"' in STYLE and ".m01lab__ho-box + .m01lab__ho-box" in STYLE def test_공통_옵션은_모든_박스에_같이(): From 27f33a8c5328d0fdae8612d8ec1b4f0e299efb83 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:20:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(751-1):=20=EC=9B=90=ED=99=94=20?= =?UTF-8?q?=EA=BC=B4=20=ED=9D=A0=20=E2=80=94=20B09=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=ED=91=9C=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=E3=80=8C.0=E3=80=8D=20=EA=BC=AC=EB=A6=AC=20=EA=B1=B7=EC=9D=8C?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EA=B0=80=20=EC=86=8C=EC=88=98=20=EC=85=8B?= =?UTF-8?q?=20=C2=B7=20=ED=99=98=EC=9C=A8=ED=91=9C=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EA=B8=88=EC=95=A1=20=EC=A0=95=EC=88=98=20?= =?UTF-8?q?=EA=BC=B4=20=EA=B3=B5=EC=9A=A9=20=EA=B0=88=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 격자 단가 칸 소수 셋 · 금액 칸 소수 하나 끝 0 없음(275,790.0 → 275,790) - B09 money() 소수 자리 있으면 끝 0 없음 · 환율표 59,020.00 → 59,020 · 단가 열 소수 셋(1,846.39 · 22.9) - 엑셀 불러오기 대조 글 빈 값 = 빈칸(0 아님) · en-US 정수 → 공용 formatMoney · 재료 창고 금액 공용 꼴 Co-Authored-By: Claude Sonnet 5.5 --- B09_Estimation/B09_Estimation_UI_Import.ts | 7 ++++--- B09_Estimation/B09_Estimation_UI_Store.ts | 7 +++---- B09_Estimation/B09_Estimation_UI_Tabs.ts | 1 + B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 17 +++++++++-------- B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 +- .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 4 ++-- .../B09_Estimation_UI_Tabs_Sangeun_Sheet.ts | 4 ++-- .../B09_Estimation_UI_Tabs_Summary.ts | 2 +- B09_Estimation/B09_Estimation_UI_Tabs_Total.ts | 3 ++- ui_template/ui_template_hopyo_grid.ts | 10 ++++++---- 10 files changed, 31 insertions(+), 26 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Import.ts b/B09_Estimation/B09_Estimation_UI_Import.ts index 7a93ac418..fd9d2f7ec 100644 --- a/B09_Estimation/B09_Estimation_UI_Import.ts +++ b/B09_Estimation/B09_Estimation_UI_Import.ts @@ -40,11 +40,13 @@ import { import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; import { currentProjectId } from "../A00_Common/b_project_switch"; +import { formatMoney, formatPrice } from "../common_util/common_util_money"; import type { Row } from "./B09_Estimation_Calc_Base"; import { openBillLink } from "./B09_Estimation_UI_Link"; import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; -const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR"); +const won = (v: unknown): string => + v === null || v === undefined || v === "" ? "빈칸" : formatPrice(Number(v)); async function post(projectId: string, path: string, body: BodyInit, json = false): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/import/${path}`, { @@ -357,8 +359,7 @@ async function postSheet(kind: Kind, user: Row, file: File | null): Promise return body!; } -const wonOrBlank = (v: unknown): string => - typeof v === "number" ? Math.round(v).toLocaleString("en-US") : ""; +const wonOrBlank = (v: unknown): string => (typeof v === "number" ? formatMoney(v) : ""); function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } { const check: Row[] = got.check?.lines || []; diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 674c12d06..5cfd44bc8 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -38,6 +38,7 @@ import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { formatMoney } from "../common_util/common_util_money"; type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past"; const SOURCES: Array<[Source, string]> = [ @@ -133,7 +134,7 @@ async function sangeunItems(): Promise { return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => { const name = r.제목 || r.이름; const sub = [r.구분, r.상세구분].filter(Boolean).join(" "); - const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`; + const money = r.기준금액 === null ? "" : `${formatMoney(r.기준금액)}원`; return { tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`, name, @@ -161,9 +162,7 @@ async function poomsemItems(): Promise { return list.줄.map((r) => ({ tag: r.공종 || "품셈", name: r.이름, - sub: [r.규격, r.금액 === null ? "" : `${Math.round(r.금액).toLocaleString("ko-KR")}원`] - .filter(Boolean) - .join(" · "), + sub: [r.규격, r.금액 === null ? "" : `${formatMoney(r.금액)}원`].filter(Boolean).join(" · "), unit: r.단위, hit: (q) => !q || matchLogic(q, { ...r, 이름: `${r.이름} ${r.규격}` }).ok, go: (_c, apply) => askStandard(apply, r), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index a91838c22..3f1283adf 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -179,6 +179,7 @@ export const money = (key: string, label: string, group?: string[], decimals = 0 group, kind: "number", decimals, + trim: decimals > 0, // 끝 0 없음(「18,064.0」 아님) width: 96, }); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index f11a5f6e6..52c23735d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -18,6 +18,7 @@ import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { formatPrice } from "../common_util/common_util_money"; import { dateText } from "../common_util/common_util_time"; import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { @@ -41,7 +42,7 @@ function priceList(rows: Row[], unitPrice: string, hint: string): TabTable { text("name", "명칭", 200), text("spec", "규격", 140), text("unit", "단위", 56), - money("price", unitPrice), + money("price", unitPrice, undefined, 3), text("source", "비고", 220), ], rows: rows.map((r) => ({ ...r })), @@ -121,7 +122,7 @@ const SUPPLY_NEXT: Record = { "": "관급", 관급: "별 function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMenuItem[] { const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); const label = (s: string): string => - `${s === r.applied_from ? "✓ " : ""}${s} ${Number(r.prices[s]).toLocaleString("ko-KR")}` + + `${s === r.applied_from ? "✓ " : ""}${s} ${formatPrice(Number(r.prices[s]))}` + (s === r.lowest_from ? " · 최저" : ""); return [ ...have.map((s): MapContextMenuItem => [label(s), () => pick(s)]), @@ -196,11 +197,11 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { // 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서 ...SOURCES.flatMap((s) => s === "견적" - ? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])] - : [money(`p:${s}`, "단가", [s])], + ? [money(`p:${s}`, "단가", [s], 3), text("files", "견적서", 72, [s])] + : [money(`p:${s}`, "단가", [s], 3)], ), - money("lowest", "최저"), - money("applied", "적용 단가", ["적용"]), + money("lowest", "최저", undefined, 3), + money("applied", "적용 단가", ["적용"], 3), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), ], @@ -294,7 +295,7 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { text("spec", "규격", 160), text("unit", "단위", 56), qty("qty", "수량", 3), - money("price", "단가"), + money("price", "단가", undefined, 3), money("amount", "총액"), text("supply", "관급 · 별도", 80), text("uses", "비고(쓰는 곳)", 240), @@ -379,7 +380,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => { links.push(null); }); const pair = (key: string, label: string) => [ - money(`${key}_price`, "단가", [label], 1), + money(`${key}_price`, "단가", [label], 3), money(key, "금액", [label], 1), ]; return { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index e2362ed8d..a1cf55f11 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -55,7 +55,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { })); const direct: Row = bill.totals?.direct || {}; const pair = (key: string, label: string) => [ - money(`price_${key}`, "단가", [label]), + money(`price_${key}`, "단가", [label], 3), money(key, "금액", [label]), ]; /** 고친 칸 — 셈 줄에 자동 값(auto_칸)이 있음 · 연결 칸 = auto_ref */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 50ff41147..483889634 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -28,6 +28,7 @@ import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu"; import { API_BASE_URL } from "@config/config_frontend"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_toast"; import { currentProjectId } from "../A00_Common/b_project_switch"; +import { formatNumber } from "../common_util/common_util_money"; import { ADD } from "./B09_Estimation_Calc_Labor"; import { withCols } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Paste"; import { @@ -57,8 +58,7 @@ interface FoldLine { /** 지금 펼친 내역 줄 키(다시 그려도 그대로) */ let open: string | null = null; -const plain = (v: unknown): string => - typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸"; +const plain = (v: unknown): string => (typeof v === "number" ? formatNumber(v) : "빈칸"); /** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */ const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값"); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts index 818243b7c..35b3ac136 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts @@ -27,6 +27,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { createText } from "@ui/ui_template_text"; import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet"; +import { formatNumber } from "../common_util/common_util_money"; import type { Row } from "./B09_Estimation_Calc_Base"; import { fields, lineId } from "./B09_Estimation_Sangeun_Cover"; import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs"; @@ -49,8 +50,7 @@ const FIELD: Record = { expense_price: "expense", }; -const fmt = (v: unknown): string => - typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸"; +const fmt = (v: unknown): string => (typeof v === "number" ? formatNumber(v) : "빈칸"); /* ── 순수 — 산근 블록 → 계산서 묶음 ── */ /** 줄이 쓰는 비목 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index 3a70481f6..585b4a8e6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -47,7 +47,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab text("spec", "규격", 140), qty("qty", "수량", 2), text("unit", "단위", 56), - money("price", unitPrice), + money("price", unitPrice, undefined, 3), money("amount", "금액"), text("note", "비고", 120), ], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index e84107e8b..d2e432fd4 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -16,6 +16,7 @@ * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; +import { formatMoney } from "../common_util/common_util_money"; import type { Row } from "./B09_Estimation_Calc_Base"; import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total"; import { @@ -31,7 +32,7 @@ import { type Num = number | null; -const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US")); +const won = (v: Num): string => (v === null ? "빈칸" : formatMoney(v)); /** 손입력 칸 — 줄 키 → user.estimate 칸 */ const HAND: Record = { diff --git a/ui_template/ui_template_hopyo_grid.ts b/ui_template/ui_template_hopyo_grid.ts index 7a2b4a5dd..b31260993 100644 --- a/ui_template/ui_template_hopyo_grid.ts +++ b/ui_template/ui_template_hopyo_grid.ts @@ -98,12 +98,14 @@ const hopyoText = (key: string, label: string, width?: number): DataColumn => ({ label, width, }); -const hopyoMoney = (key: string, label: string, group: string): DataColumn => ({ +/** 단가 = 소수 셋까지 · 금액 = 소수 하나까지 — 끝 0 없음(`275,790` · `109,326.3`) */ +const hopyoMoney = (key: string, label: string, group: string, decimals: number): DataColumn => ({ key, label, group: [group], kind: "number", - decimals: 1, + decimals, + trim: true, width: 96, }); @@ -114,8 +116,8 @@ export const HOPYO_COLUMNS: DataColumn[] = [ { key: "qty", label: "수량", kind: "number", decimals: 4, trim: true, width: 76 }, hopyoText("unit", "단위", 56), ...HOPYO_COSTS.flatMap(([key, label]) => [ - hopyoMoney(`${key}_price`, "단가", label), - hopyoMoney(key, "금액", label), + hopyoMoney(`${key}_price`, "단가", label, 3), + hopyoMoney(key, "금액", label, 1), ]), hopyoText("note", "비고", 120), hopyoText("tag", "구분", 56), From 1da41d661370dbee3dbbdfb5d013e0fb9e082898 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:23:20 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test(748-2):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20test=5F71?= =?UTF-8?q?7=5F2b=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20=EA=B8=B8=20?= =?UTF-8?q?=EB=8C=80=EC=A1=B0(cover=20=EC=97=B4=EA=B8=B0=20=C2=B7=20hoCtx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_717_2b_sangeun_detail.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index 3489bc31b..23cb21baf 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -174,11 +174,14 @@ def test_상세_부품_길_부품상자(): cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts") assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail - assert "buildLabDetail(editor" in detail and "cover: { ho, est }" in detail + assert ( + "buildLabDetail(editor" in detail + and "cover: { ho, est, open: lineOpener(hoCtx) }" in detail + ) assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail assert "if (mine !== seq || !alive) return" in detail # 최신 응답만 # 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음 - assert "...buildHo({" in detail and 'scope: "m02",' in detail + assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail assert "setLineCover(doc, r, c, opened());" in detail and "skCoverTable" not in detail ho = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts") assert "edit.more?.(e.idx, close)" in ho and '"EditedM02"' in ho