From c201a1269d16fe9dc0c26ca69b8e09d699e51ace Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 10:08:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(728-1):=20SK=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=ED=99=98=EC=82=B0=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=C2=B7=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=20?= =?UTF-8?q?=C2=B7=20TS=20=EC=A7=9D=20=C2=B7=20=EA=B1=B0=EC=9A=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_unit.swap · split — 같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량 · 그 밖 = 사용자 계수 · 계수 없으면 배 없음 + 까닭 글 - A00_Common/master_unit_swap.ts swapUnit — 같은 답(단위 표 · 별칭 · 분모 읽기 옮김) - 지킴 시험 test_728_1_unit_swap.py(거울 20 사례) Co-Authored-By: Claude Opus 5.5 --- A00_Common/master_unit_swap.ts | 132 +++++++++++++++++++ resources/master_data/scripts/master_unit.py | 40 ++++++ resources/tester/helper_728_1_unit_swap.cjs | 12 ++ resources/tester/test_728_1_unit_swap.py | 114 ++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 A00_Common/master_unit_swap.ts create mode 100644 resources/tester/helper_728_1_unit_swap.cjs create mode 100644 resources/tester/test_728_1_unit_swap.py diff --git a/A00_Common/master_unit_swap.ts b/A00_Common/master_unit_swap.ts new file mode 100644 index 000000000..6ac088014 --- /dev/null +++ b/A00_Common/master_unit_swap.ts @@ -0,0 +1,132 @@ +/* ============================================================================= + * master_unit_swap.ts + * SK 단위 바꿈 환산(728-1) — 짝(①): `resources/master_data/scripts/master_unit.py` `swap` · `split`. + * 거울 시험 `resources/tester/test_728_1_unit_swap.py`. 단위 표 · 별칭 · 분모 읽기는 그 모듈 그대로 옮김 + * (한 벌 — 고칠 때 두 파일 함께). 화면 · fetch · 저장 없음. + * ========================================================================== */ + +const ALIAS: Record = { + ea: "개", + ton: "톤", + t: "톤", + l: "ℓ", + 리터: "ℓ", + set: "조", + 세트: "조", +}; +const UNITS: Record> = { + 무게: { g: 1, kg: 1000, 톤: 1000000 }, + 길이: { mm: 1, cm: 10, m: 1000, km: 1000000 }, + 넓이: { cm2: 1, m2: 10000 }, + 부피: { cm3: 1, ℓ: 1000, m3: 1000000 }, + 개수: { 개: 1, 본: 1, 매: 1 }, +}; +const SCALE: Record = {}; +for (const [kind, group] of Object.entries(UNITS)) { + for (const [u, n] of Object.entries(group)) SCALE[u] = [kind, n]; +} +const WEIGHT_TABLE = "CC000337"; +const PER = /원\s*\/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)/; + +export type UnitNeed = null | "단위중량" | "사용자"; +export interface UnitSwap { + 가: [number, string]; + 나: [number, string]; + 성격: [string | null, string | null]; + /** have 1 개가 want 몇 개 — 1 단위 당 값은 ÷배 · 수량은 ×배 · 계수 없으면 null(셈 막음) */ + 배: number | null; + 계수: UnitNeed; + 근거: string; +} + +export function plainUnit(unit: unknown): string { + const s = String(unit ?? "") + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, "") + .replace(/ℓ/g, "l"); + return ALIAS[s] ?? s; +} + +export function unitKind(unit: unknown): string | null { + return SCALE[plainUnit(unit)]?.[0] ?? null; +} + +function sameKind(a: unknown, b: unknown): boolean { + const k = unitKind(a); + return plainUnit(a) === plainUnit(b) || (k !== null && k === unitKind(b)); +} + +function weighs(a: unknown, b: unknown): boolean { + const ks = new Set([unitKind(a), unitKind(b)]); + return ks.size === 2 && ks.has("무게") && ks.has("부피"); +} + +/** `have` 1 단위가 `want` 몇 단위 — 부피 ↔ 무게는 단위중량(㎏/㎥) 있을 때만 · 못 하면 null */ +function factor(have: string, want: string, density?: number | null): number | null { + const a = plainUnit(have); + const b = plainUnit(want); + if (a === b) return 1; + if (sameKind(a, b)) return SCALE[a][1] / SCALE[b][1]; + if (density && weighs(a, b)) { + const gramsPerCm3 = density / 1000; + const base = SCALE[a][1] * (unitKind(a) === "부피" ? gramsPerCm3 : 1 / gramsPerCm3); + return base / SCALE[b][1]; + } + return null; +} + +/** 열두 자리로 다듬음 — 파이썬 Decimal 셈과 같은 값(뜬수 끝 흔들림 없앰) */ +function tidy(x: number): number { + return Number(x.toPrecision(12)); +} + +function num(x: number): string { + return Number.isInteger(x) ? String(x) : String(x).slice(0, 10); +} + +/** 기준단위 앞 수 떼기 — 「100본」 → [100, 본] · 「㎥」 → [1, ㎥] */ +export function splitUnit(unit: unknown): [number, string] { + const text = String(unit ?? "").trim(); + const got = text ? PER.exec(`원/${text}`) : null; + if (!got) return [1, text]; + return [got[1] ? Number(got[1].replace(/,/g, "")) : 1, got[2]]; +} + +/** SK 단위 `have` → `want` 환산 — 같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량 · 그 밖 = 사용자 계수 + * (have 앞 수 뗀 1 단위 = want 앞 수 뗀 몇 단위 · 0 보다 커야). */ +export function swapUnit( + have: string, + want: string, + density?: number | null, + coef?: number | null, +): UnitSwap { + const [na, ua] = splitUnit(have); + const [nb, ub] = splitUnit(want); + const base = { 가: [na, ua], 나: [nb, ub], 성격: [unitKind(ua), unitKind(ub)] } as Pick< + UnitSwap, + "가" | "나" | "성격" + >; + if (!ua || !ub) return { ...base, 배: 1, 계수: null, 근거: "" }; + const need: UnitNeed = sameKind(ua, ub) ? null : weighs(ua, ub) ? "단위중량" : "사용자"; + const f = need === "사용자" ? (coef && coef > 0 ? coef : null) : factor(ua, ub, density); + if (f === null) { + const why = + need === "단위중량" + ? `단위중량(${WEIGHT_TABLE}) 골라야 셈` + : "성격 다름 · 환산 계수 넣어야 셈"; + return { ...base, 배: null, 계수: need, 근거: `${have} ↔ ${want} — ${why}` }; + } + const ratio = tidy((na * f) / nb); + const how = + need === "단위중량" + ? ` · 단위중량 ${density}㎏/㎥` + : need === "사용자" + ? ` · 계수 1 ${ua} = ${coef} ${ub}` + : ""; + let step = ""; + if (ratio === 1) step = plainUnit(have) === plainUnit(want) ? "" : `${have} = ${want}`; + else + step = `${have} 당 → ${want} 당 ${ratio > 1 ? `÷${num(ratio)}` : `×${num(tidy(1 / ratio))}`}`; + return { ...base, 배: ratio, 계수: need, 근거: step + how }; +} diff --git a/resources/master_data/scripts/master_unit.py b/resources/master_data/scripts/master_unit.py index 07d8e56f2..2a5fb2a4e 100644 --- a/resources/master_data/scripts/master_unit.py +++ b/resources/master_data/scripts/master_unit.py @@ -4,6 +4,7 @@ 쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) · 조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) · 표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀. +- 단 SK 단위 바꿈 환산(`swap` · 728-1)은 짝(①) — `A00_Common/master_unit_swap.ts` · 거울 시험 `test_728_1_unit_swap.py`. - 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿). - 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음 (사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만. @@ -119,3 +120,42 @@ def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | No got = row.get(end) return None if got is None else Decimal(str(got)) return None + + +def split(unit) -> tuple[Decimal, str]: + """기준단위 앞 수 떼기 — 「100본」 → (100, 본) · 「㎥」 → (1, ㎥) · 결과단위 분모 읽기(`per`) 그대로.""" + got = per(f"원/{unit}") if str(unit or "").strip() else [] + return got[0] if got else (Decimal(1), str(unit or "").strip()) + + +def swap(have, want, density=None, coef=None) -> dict: + """SK 단위 `have` → `want` 환산(728-1) — `배` = have 1 개가 want 몇 개(1 단위 당 값은 ÷배 · 수량은 ×배). + 같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량(㎏/㎥) `density` · 그 밖 성격 다름 = 사용자 `coef` + (have 앞 수 뗀 1 단위 = want 앞 수 뗀 몇 단위 · 0 보다 커야) · 계수 없으면 `배` None + 까닭 글(셈 막음).""" + (na, ua), (nb, ub) = split(have), split(want) + out = {"가": [na, ua], "나": [nb, ub], "성격": [kind_of(ua), kind_of(ub)]} + if not ua or not ub: + return {**out, "배": Decimal(1), "계수": None, "근거": ""} + need = None if same_kind(ua, ub) else ("단위중량" if _weighs(ua, ub) else "사용자") + if need == "사용자": + f = Decimal(str(coef)) if coef and Decimal(str(coef)) > 0 else None + else: + f = factor(ua, ub, density) + if f is None: + why = ( + f"단위중량({WEIGHT_TABLE}) 골라야 셈" + if need == "단위중량" + else "성격 다름 · 환산 계수 넣어야 셈" + ) + return {**out, "배": None, "계수": need, "근거": f"{have} ↔ {want} — {why}"} + ratio = na * f / nb + how = { + "단위중량": f" · 단위중량 {density}㎏/㎥", + "사용자": f" · 계수 1 {ua} = {coef} {ub}", + }.get(need, "") + if ratio == 1: + step = "" if plain(have) == plain(want) else f"{have} = {want}" + else: + by = f"÷{_num(ratio)}" if ratio > 1 else f"×{_num(1 / ratio)}" + step = f"{have} 당 → {want} 당 {by}" + return {**out, "배": ratio, "계수": need, "근거": step + how} diff --git a/resources/tester/helper_728_1_unit_swap.cjs b/resources/tester/helper_728_1_unit_swap.cjs new file mode 100644 index 000000000..00e2ec442 --- /dev/null +++ b/resources/tester/helper_728_1_unit_swap.cjs @@ -0,0 +1,12 @@ +/* PLAN 728-1 거울 헬퍼 — `A00_Common/master_unit_swap.ts` 를 Node 로 돌려 표준입력 사례마다 답 JSON. + * 짝 = `test_728_1_unit_swap.py`(같은 사례를 `master_unit.swap` 으로 돌려 맞댐). + */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ root: ROOT }); +const mod = load("A00_Common", "master_unit_swap.ts"); +const cases = JSON.parse(fs.readFileSync(0, "utf-8")); +process.stdout.write(JSON.stringify(cases.map(([a, b, d, c]) => mod.swapUnit(a, b, d, c)))); diff --git a/resources/tester/test_728_1_unit_swap.py b/resources/tester/test_728_1_unit_swap.py new file mode 100644 index 000000000..37e787471 --- /dev/null +++ b/resources/tester/test_728_1_unit_swap.py @@ -0,0 +1,114 @@ +"""728-1 지킴 — SK 단위 바꿈 환산 짝: `master_unit.swap` ↔ `A00_Common/master_unit_swap.ts` `swapUnit` 거울. + +같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량 · 그 밖 = 사용자 계수 · 계수 없으면 배 없음 + 까닭 글. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) + +import master_unit as mu # noqa: E402 + +CASES = [ + ["100본", "본", None, None], + ["10㎡", "㎡", None, None], + ["1,000본", "100본", None, None], + ["㎡", "10㎡", None, None], + ["m", "km", None, None], + ["kg", "톤", None, None], + ["본", "개", None, None], + ["m3", "㎥", None, None], + ["㎥", "㎥", None, None], + ["식", "식", None, None], + ["", "본", None, None], + ["㎥", "m", None, None], + ["㎥", "m", None, 2.5], + ["㎥", "m", None, -1], + ["100m", "㎡", None, 0.5], + ["㎥", "톤", None, None], + ["㎥", "톤", 1600, None], + ["톤", "㎥", 1600, None], + ["10㎥", "kg", 2400, None], + ["조", "개", None, None], +] + + +def _py(case) -> dict: + got = mu.swap(*case) + num = lambda x: None if x is None else float(x) # noqa: E731 + return { + **got, + "가": [num(got["가"][0]), got["가"][1]], + "나": [num(got["나"][0]), got["나"][1]], + "배": num(got["배"]), + } + + +@pytest.fixture(scope="module") +def ts() -> list[dict]: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_728_1_unit_swap.cjs")], + input=json.dumps(CASES), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_거울_파이썬과_TS_같은_답(ts) -> None: + for case, t in zip(CASES, ts, strict=True): + p = _py(case) + assert (t["가"], t["나"], t["성격"], t["계수"], t["근거"]) == ( + p["가"], + p["나"], + p["성격"], + p["계수"], + p["근거"], + ), case + assert (t["배"] is None) == (p["배"] is None), case + if p["배"] is not None: + assert t["배"] == pytest.approx(p["배"], rel=1e-12), case + + +def test_같은_성격은_배_자동() -> None: + assert mu.swap("100본", "본")["배"] == 100 # 단목베기 100본 당 153,889.4 → 본 당 1,538.894 + assert Decimal("153889.4") / mu.swap("100본", "본")["배"] == Decimal("1538.894") + assert Decimal("447913.20") / mu.swap("10㎡", "㎡")["배"] == Decimal("44791.32") + assert mu.swap("100본", "본")["근거"] == "100본 당 → 본 당 ÷100" + assert mu.swap("m", "km")["근거"] == "m 당 → km 당 ×1000" + assert mu.swap("본", "개")["계수"] is None and mu.swap("본", "개")["근거"] == "본 = 개" + assert mu.split("1,000본") == (Decimal(1000), "본") and mu.split("㎥") == (Decimal(1), "㎥") + + +def test_다른_성격은_계수_없으면_셈_막음() -> None: + got = mu.swap("㎥", "m") + assert got["배"] is None and got["계수"] == "사용자" and "환산 계수" in got["근거"] + assert mu.swap("㎥", "m", coef=2.5)["배"] == Decimal("2.5") + assert mu.swap("㎥", "m", coef=0)["배"] is None # 0 · 음수 = 계수 없음 + got = mu.swap("㎥", "톤") + assert got["배"] is None and got["계수"] == "단위중량" and "CC000337" in got["근거"] + assert mu.swap("㎥", "톤", density=1600)["배"] == Decimal("1.6") + assert mu.swap("조", "개")["계수"] == "사용자" # 한 조 = 여럿 — 묶음 밖 + + +def test_TS_는_새_정규식_없이_모듈_한_벌() -> None: + src = (ROOT / "A00_Common/master_unit_swap.ts").read_text(encoding="utf-8") + assert "master_unit.py" in src and "test_728_1_unit_swap.py" in src + py = (ROOT / "resources/master_data/scripts/master_unit.py").read_text(encoding="utf-8") + assert "master_unit_swap.ts" in py and "test_728_1_unit_swap.py" in py + for kind, group in mu.UNITS.items(): # 단위 표가 두 파일에 같게 + for u, n in group.items(): + assert f"{u}: {n}" in src or f'"{u}": {n}' in src, (kind, u)