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("가") == "가"