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:
@@ -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))));
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user