feat(712-2): 단위 한 벌 master_unit — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 근거 글

- 별칭(master_material) · 같은 성격 환산(master_pick) · 결과단위 배 읽기 셋(Store_Combo · Hopyo_Ours · master_basis) · 보이는 꼴 별칭(master_basis) 갈아 끼움 · 엑셀 맞춤(Hopyo_Match)은 master_pick 이름 그대로 이음
- 개수 묶음(개 · EA · 본 · 매) 1:1 — 본 ↔ 개 단위경고 4 줄 사라짐 · 값 같음
- 부피 ↔ 무게 = 단위중량 표 CC000337 고른 줄 받을 때만(기본 줄 없음) · 밀도 · ㎾h 경고 글만
- B08 /1000 · /40 손 식 = 실무 엑셀 G열 식이라 그대로(시험이 같은 값 대조)
- 지킴 시험 test_712_2_unit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 16:03:54 +09:00
co-authored by Claude Opus 5.5
parent 6d5b78f83e
commit 881c204ca8
7 changed files with 195 additions and 49 deletions
+5 -7
View File
@@ -17,6 +17,7 @@ from config import config_system
from M01_MasterData import M01_MasterData_Store as store
cm, mk, mcb = store.cm, store.mk, store.mcb
import master_unit as mu # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤(712-2)
FILE = mcb.FILE
OWNERS = mcb.OWNERS
@@ -150,9 +151,6 @@ def combo(key: str) -> dict:
}
_PER = re.compile(
r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)"
) # 「원/1,000본」 = 배 1000
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
@@ -177,7 +175,7 @@ def combo_pills(row: dict) -> dict:
got = whole.logic(code)
except cm.mf.FormulaError:
continue
per = _PER.findall(str(got.get("결과단위") or ""))
per = mu.per(got.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
if not per:
continue
# ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름
@@ -187,9 +185,9 @@ def combo_pills(row: dict) -> dict:
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
)
pill["로직"].append(code)
times = per[0][0].replace(",", "")
if len(per) == 1 and times and float(times) != 1:
pill["배"] = float(times) if "." in times else int(times)
times = per[0][0]
if len(per) == 1 and times != 1:
pill["배"] = int(times) if times == times.to_integral_value() else float(times)
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
+3 -4
View File
@@ -32,10 +32,9 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
mb = m01.cm.mf.mb # 묶음 · 기계경비 판별(712-1)
mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
ROOT = Path(__file__).resolve().parents[1]
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
@@ -96,8 +95,8 @@ def eq_of(code: Any) -> tuple[set[str], set[float]]:
def times_of(row: dict[str, Any]) -> float:
per = _PER.findall(str(row.get("결과단위") or ""))
return float(per[0].replace(",", "")) if per and per[0] else 1.0
per = mu.per(row.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2)
return float(per[0][0]) if per else 1.0
def _args(text: str | None) -> dict[str, str]:
@@ -15,10 +15,9 @@ import re
from decimal import Decimal
from functools import lru_cache
import master_unit as mu
from check_master_body import section_of
# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움
_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
_UNITS = sorted(
(
"㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 "
@@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile(
)
_HANGUL = re.compile(r"[가-힣]")
_HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」
_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」
def unit(text: str) -> str:
return _ALIAS.get(text, text)
return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`)
def _times(text: str | None) -> Decimal:
@@ -97,7 +95,7 @@ def basis(table: dict) -> dict:
def result_per(result_unit) -> list[tuple[Decimal, str]]:
"""로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿."""
return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))]
return [(n, unit(u)) for n, u in mu.per(result_unit)]
def _formula_text(row: dict) -> str:
@@ -12,6 +12,8 @@ import re
import unicodedata
from decimal import Decimal
import master_unit as mu
LINKED = "MP" # 품셈재료 테이블ID
MARKET = "MT" # 자재품목 테이블ID
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
@@ -27,7 +29,6 @@ PICK_KEYS = (
) # 재료 고르기 조건 칸 (`_틀.md` 7장)
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
def plain(s) -> str:
@@ -41,8 +42,7 @@ def plain_spec(s) -> str:
def plain_unit(s) -> str:
s = plain(s).replace("ℓ", "l")
return _UNITS.get(s, s)
return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2)
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
+1 -30
View File
@@ -9,18 +9,10 @@ from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from decimal import Decimal
import master_material as mm
from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2)
# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴)
UNITS = {
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
"넓이": {"cm2": 1, "m2": 10000},
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
}
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
REGIONS = (
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
" 전남광주"
@@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool:
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
# ── 단위 ───────────────────────────────────────────────────────────────
def kind_of(unit) -> str | None:
got = _SCALE.get(mm.plain_unit(unit))
return got[0] if got else None
def same_kind(a, b) -> bool:
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피)."""
pa, pb = mm.plain_unit(a), mm.plain_unit(b)
return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b))
def convert(price, have, want):
"""`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로."""
if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want):
return price
if not same_kind(have, want) or kind_of(have) is None:
return price
return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1])
# ── 찾기 ───────────────────────────────────────────────────────────────
def _words(term) -> tuple[list[str], list[str]]:
"""(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임."""
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2).
쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) ·
조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) ·
표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀.
- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿).
- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음
(사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만.
"""
from __future__ import annotations
import re
import unicodedata
from decimal import Decimal
# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤
ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로)
SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"}
# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}}
UNITS = {
"무게": {"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},
}
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥)
# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본)
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)")
def plain(unit) -> str:
"""맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」."""
s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower())
s = s.replace("ℓ", "l")
return ALIAS.get(s, s)
def shown(unit) -> str:
return SHOWN.get(unit, unit)
def per(result_unit) -> list[tuple[Decimal, str]]:
"""결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿."""
return [
(Decimal(n.replace(",", "")) if n else Decimal(1), u)
for n, u in _PER.findall(str(result_unit or ""))
]
def kind_of(unit) -> str | None:
got = _SCALE.get(plain(unit))
return got[0] if got else None
def same_kind(a, b) -> bool:
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수)."""
return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b))
def _weighs(a, b) -> bool:
return {kind_of(a), kind_of(b)} == {"무게", "부피"}
def factor(have, want, density=None) -> Decimal | None:
"""`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×.
부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None."""
a, b = plain(have), plain(want)
if a == b:
return Decimal(1)
if same_kind(a, b):
return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1])
if density and _weighs(a, b):
grams_per_cm3 = Decimal(str(density)) / Decimal(1000)
base = Decimal(_SCALE[a][1]) * (
grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3
)
return base / Decimal(_SCALE[b][1])
return None
def convert(price, have, want, density=None):
"""`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽)."""
if price is None or not want or not have or plain(have) == plain(want):
return price
if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수)
return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1])
got = factor(want, have, density) # want 1 단위 = have 몇 단위
return price if got is None or got == 1 else price * got
def note(have, want, density=None) -> str:
"""환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭."""
if not have or not want or plain(have) == plain(want):
return ""
got = factor(want, have, density)
if got is not None:
if kind_of(have) == "개수" and got == 1:
return f"{have} = {want}(개수 1:1)"
how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else ""
return f"{have} → {want} ×{_num(got)}{how}"
if _weighs(have, want):
return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로"
return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)"
def _num(x: Decimal) -> str:
return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10]
def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None:
"""단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None)."""
for row in master.get(WEIGHT_TABLE).get("줄") or []:
if row.get("종별") == kind and row.get("형상") == shape:
got = row.get(end)
return None if got is None else Decimal(str(got))
return None
+59
View File
@@ -0,0 +1,59 @@
"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수."""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
sys.path.insert(0, str(ROOT))
import check_master as cm # noqa: E402
import master_basis as mbs # noqa: E402
import master_formula as mf # noqa: E402
import master_material as mm # noqa: E402
import master_pick as mp # noqa: E402
import master_unit as mu # noqa: E402
def test_alias_and_count_group():
assert mu.plain("EA") == mu.plain("ea") == "개"
assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤"
assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ"
assert mu.same_kind("본", "개") and mu.same_kind("매", "EA")
assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖
assert mu.note("본", "개") == "본 = 개(개수 1:1)"
assert mm.plain_unit("EA") == mu.plain("EA")
assert mp.convert is mu.convert and mp.UNITS is mu.UNITS
def test_same_kind_convert():
assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원
assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000)
assert mu.convert(Decimal(7), "m", "km") == Decimal(7000)
assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값
assert mu.note("kg", "톤") == "kg → 톤 ×1000"
assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로
assert "성격 다름" in mu.note("ℓ", "㎾h")
def test_volume_weight_only_when_picked():
"""부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음."""
master = mf.Master(cm.load())
assert mu.factor("㎥", "톤") is None
assert "단위중량(CC000337)" in mu.note("㎥", "톤")
rho = mu.unit_weight(master, "모래", "건조")
assert rho is not None and rho > 1000
assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤
assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho
assert mu.unit_weight(master, "없는 종별", "건조") is None
def test_result_per_one_parser():
assert mu.per("원/10㎡") == [(Decimal(10), "㎡")]
assert mu.per("원/1,000본") == [(Decimal(1000), "본")]
assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")]
assert mu.per("㎥/시간") == []
assert mbs.result_per("원/ton") == [(Decimal(1), "ton")]