feat(728-1): SK 단위 바꿈 환산 순수 함수 · 파이썬 · TS 짝 · 거울 시험

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 10:08:02 +09:00
co-authored by Claude Opus 5.5
parent 638d3f446d
commit c201a1269d
4 changed files with 298 additions and 0 deletions
+132
View File
@@ -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<string, string> = {
ea: "개",
ton: "톤",
t: "톤",
l: "ℓ",
리터: "ℓ",
set: "조",
세트: "조",
};
const UNITS: Record<string, Record<string, number>> = {
무게: { 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<string, [string, number]> = {};
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 };
}
@@ -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}
@@ -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))));
+114
View File
@@ -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)