From 6d5b78f83eecbbd2df814356c0f5a43b23484c86 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:02:13 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(712-1):=20=EC=9D=B8=EB=A0=A5=20=C2=B7?= =?UTF-8?q?=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EA=B2=BD=EB=B9=84=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=85=88=20=EB=95=8C=20=ED=8C=8C=EC=83=9D=20=C2=B7?= =?UTF-8?q?=20=EA=B8=B0=EA=B3=84=EA=B2=BD=EB=B9=84=20=ED=8C=90=EB=B3=84=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_bundle — 호표 · 덧줄 = 비목 따라 · 비목 없는 로직 줄 = 기계경비면 경비 + 장비 · 아니면 하위 - 기계경비 판별 = 결과단위 원/시간(is_machine) — M02 엑셀 맞춤(이름 「시간당」) · B09 산근 · 산근 텍스트(입력 「기계」) 갈아 끼움 - 엔진 run 줄 덧칸 「묶음」 · 기계경비 줄 「장비」(출력 꼬리표만 · 노 · 재 · 경 몫은 줄 비목 그대로) - 정본 칸 안 더함 · 5,911 줄 중 하위 31 · 기계경비 줄 1,044 - 지킴 시험 test_712_1_bundle Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 2 +- B09_Estimation/B09_Estimation_Sangeun_Text.py | 2 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 3 +- .../master_data/scripts/master_bundle.py | 60 +++++++++++++++++ .../master_data/scripts/master_formula.py | 2 + resources/tester/test_712_1_bundle.py | 64 +++++++++++++++++++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/master_bundle.py create mode 100644 resources/tester/test_712_1_bundle.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 02549d389..6364407a4 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -242,7 +242,7 @@ def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None: target = None if not target: return None - if any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = _captured(whole, node, env) return {"machine": keys[0] if keys else None} given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 84c005b79..53d112e4d 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -276,7 +276,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic except mf.FormulaError: target = None name = item.get("이름", "") - if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = sg._captured(whole, node, env) got = price_of(keys[0]) if keys else None if not got: diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 92272c4ad..90c6619c1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -32,6 +32,7 @@ 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) FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] _PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?") @@ -84,7 +85,7 @@ def whole() -> Any: def is_mach_logic(row: dict[str, Any]) -> bool: - return "시간당" in str(row.get("이름") or "") + return mb.is_machine(row) # 기계경비 판별 한 곳(712-1 · 결과단위 원/시간) def eq_of(code: Any) -> tuple[set[str], set[float]]: diff --git a/resources/master_data/scripts/master_bundle.py b/resources/master_data/scripts/master_bundle.py new file mode 100644 index 000000000..282e813cc --- /dev/null +++ b/resources/master_data/scripts/master_bundle.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5). + +규칙 한 줄: +- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비). +- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위. + 두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다). + +기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) · +B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀. +엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true. +""" + +from __future__ import annotations + +import re + +import master_formula as mf + +BUNDLES = ("인력", "재료", "경비", "하위") +BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"} +BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침 + + +def is_machine(row: dict | None) -> bool: + """기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님).""" + if not row or "결과" in row or isinstance(row.get("흐름"), dict): + return False + return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간" + + +def called(master, item: dict, env: dict | None = None) -> dict | None: + """로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None.""" + try: + node = mf.parse(item.get("요소") or "") + if not node or node[0] != "logic": + return None + key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1] + return master.logic(key) + except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError): + return None + + +def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]: + """줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가).""" + if item.get("비목") in BY_ITEM: + return BY_ITEM[item["비목"]], False + if item.get("종류") == "로직": + return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False) + return BY_KIND.get(str(item.get("종류")), "하위"), False + + +def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]: + """엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩.""" + for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines): + name, machine = bundle(master, item, env) + line["묶음"] = name + if machine: + line["장비"] = True + return lines diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 4fdda195a..29c4665bb 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: } ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 + mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1) return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} @@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음 from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 _check_cut, _check_node, diff --git a/resources/tester/test_712_1_bundle.py b/resources/tester/test_712_1_bundle.py new file mode 100644 index 000000000..272a93b0c --- /dev/null +++ b/resources/tester/test_712_1_bundle.py @@ -0,0 +1,64 @@ +"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`).""" + +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_auto as mau # noqa: E402 +import master_bundle as mb # noqa: E402 +import master_formula as mf # noqa: E402 + +FILES = cm.load() +MASTER = mf.Master(FILES) +LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []} + + +def test_every_line_bundled(): + """호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비.""" + count = dict.fromkeys(mb.BUNDLES, 0) + machine = 0 + for row in LOGICS.values(): + for item in [*row.get("호표", []), *row.get("덧줄", [])]: + name, is_mach = mb.bundle(MASTER, item) + count[name] += 1 + machine += is_mach + if is_mach: + assert name == "경비" + assert sum(count.values()) > 5_000 + assert count["하위"] <= 31, count + assert machine >= 1_000, machine + + +def test_machine_rule_one_place(): + """기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님.""" + machines = {k for k, r in LOGICS.items() if mb.is_machine(r)} + assert {"GC000267", "GC000268", "GF000145"} <= machines + assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡ + sys.path.insert(0, str(ROOT)) + from M02_MasterTemplete import M02_Hopyo_Ours as ours + + assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values()) + + +def test_run_lines_tagged(): + """엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫.""" + seen = set() + for key in ("GF000169", "GF000103"): + row = LOGICS[key] + given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None} + got = mf.run(MASTER, key, given) + for line in got["줄"]: + assert line["묶음"] in mb.BUNDLES + seen.add(line["묶음"]) + if line.get("장비"): + assert set(line["비목"]) == set(mf.COST_ITEMS) + total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"]) + assert abs(total - got["계"]) < 1 + assert {"인력", "재료", "하위"} <= seen From 881c204ca85a32e81eac2e7736d8e1d44959f64c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:03:54 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(712-2):=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C=20master=5Funit=20=E2=80=94=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=C2=B7=20=EA=B0=9C=EC=88=98=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B0=EA=B3=BC=EB=8B=A8=EC=9C=84=20=EB=B0=B0=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=84=B1?= =?UTF-8?q?=EA=B2=A9=20=ED=99=98=EC=82=B0=20=C2=B7=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 별칭(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 --- M01_MasterData/M01_MasterData_Store_Combo.py | 12 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 7 +- resources/master_data/scripts/master_basis.py | 8 +- .../master_data/scripts/master_material.py | 6 +- resources/master_data/scripts/master_pick.py | 31 +---- resources/master_data/scripts/master_unit.py | 121 ++++++++++++++++++ resources/tester/test_712_2_unit.py | 59 +++++++++ 7 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 resources/master_data/scripts/master_unit.py create mode 100644 resources/tester/test_712_2_unit.py diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 2b7163325..8034e63eb 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -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()), "조건": []} diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 90c6619c1..f46178624 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -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]: diff --git a/resources/master_data/scripts/master_basis.py b/resources/master_data/scripts/master_basis.py index cf60c6928..ba753509b 100644 --- a/resources/master_data/scripts/master_basis.py +++ b/resources/master_data/scripts/master_basis.py @@ -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: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 569ea6025..beac0589c 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -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]]: diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index b656e47fc..fdbcb02a2 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -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]]: """(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임.""" diff --git a/resources/master_data/scripts/master_unit.py b/resources/master_data/scripts/master_unit.py new file mode 100644 index 000000000..07d8e56f2 --- /dev/null +++ b/resources/master_data/scripts/master_unit.py @@ -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 diff --git a/resources/tester/test_712_2_unit.py b/resources/tester/test_712_2_unit.py new file mode 100644 index 000000000..c09f638b5 --- /dev/null +++ b/resources/tester/test_712_2_unit.py @@ -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")] From 98757431816a6c0919e5e6525e76314f09264093 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:02 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(714-1):=20=EA=B3=84=EC=82=B0=EC=84=9C?= =?UTF-8?q?=20=EC=A4=84=20=EB=8D=A7=EC=B9=B8=20=E2=80=94=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=EB=8B=A8=EC=9C=84=20=C2=B7=20?= =?UTF-8?q?=ED=99=98=EC=82=B0=20=EA=B7=BC=EA=B1=B0=20=C2=B7=20M01=20/text?= =?UTF-8?q?=20=EC=99=80=20M02=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=A4=84=20=EA=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_sheet_line.annotate — master_text.lines 답에 줄마다 덧칸(줄 차례 = 호표 _shares 비목 · 뒤 덧줄) · 머리 기준단위(결과단위 분모) - 쓰는입력 = 수량 식 · 요소 · 덧줄 식이 쓰는 로직 입력(중간값 거슬러 감) · 기준단위 = 줄이 찾는 표 기준(712-3 basis) · 환산근거 = 표 기준배 ↔ 결과단위 배 · 재료 품목 단위 ↔ 줄 단위(712-2 note) - M01 /text = 새 Store_Sheet 가 감쌈(Store.py · master_text.py 700 줄 근처라 안 늘림) · M02 /logic-lib/sheet 줄 칸에 같은 KEYS - 지킴 시험 test_714_1_sheet_line(두 길 같은 덧칸) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 3 +- M01_MasterData/M01_MasterData_Store_Sheet.py | 27 +++ M02_MasterTemplete/M02_Logic_Standard.py | 8 +- .../master_data/scripts/master_sheet_line.py | 161 ++++++++++++++++++ resources/tester/test_714_1_sheet_line.py | 62 +++++++ 5 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Sheet.py create mode 100644 resources/master_data/scripts/master_sheet_line.py create mode 100644 resources/tester/test_714_1_sheet_line.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 273f709ad..9a4be5281 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Sheet as sheet import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) @@ -269,7 +270,7 @@ def post_text(body: CalcBody) -> dict: if got is not None: return got with store.cm.mf.mp.choosing(body.고름): - return _call(store.text, body.key, body.inputs, body.row, body.file) + return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1) @router.post("/save") diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py new file mode 100644 index 000000000..7d893f62b --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -0,0 +1,27 @@ +"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 · +환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함. +`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 + + +def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict: + got = store.text(key, inputs, row, file) + if not isinstance(got, dict) or not got.get("ok"): + return got + given = {k: store._dec(v) for k, v in inputs.items()} + try: + if row is None: + master = store.loaded()[1] + else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본 + files = store.cm.load(folder=store.FOLDER) + key = store._swap(files, str(row.get("키") or key), row, file) + master = mf.Master(files) + return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return got diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index df5b668b5..fd9959833 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers +mf = m01.cm.mf +import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠 + SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) @@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) filled = m01.cm.mf.fill_empty(logic, given) - got = m01.mtx.lines(files, key, filled) + got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) done = settle.settle(whole, key, filled) if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) @@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict: "비목": g["비목"], "소계": g.get("소계"), "끝수": g.get("끝수") or "", - "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + "줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]], } for g in got.get("groups") or [] ] @@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict: "입력": given, "묶음": groups, "계": got.get("계"), + "기준단위": got.get("기준단위"), } ) diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py new file mode 100644 index 000000000..1fe93a425 --- /dev/null +++ b/resources/master_data/scripts/master_sheet_line.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8). + +`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet` +두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌. +- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · + 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). +- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). +- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 + `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). +- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 + (712-2 `master_unit.note`) · 없으면 「」. +줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +import master_basis as mbs +import master_bundle as mb +import master_formula as mf +import master_text as mtx +import master_unit as mu + +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") +_HOLE = re.compile(r"\{([^{}]+)\}") +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def _names(expr) -> tuple[set[str], set[str]]: + """식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈.""" + if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만 + return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set() + if expr in (None, ""): + return set(), set() + try: + tree = mf.parse(expr) + except mf.FormulaError: + return set(_HOLE.findall(str(expr))), set() + names, tables = set(), set() + for node in mf.walk(tree): + if node[0] == "name": + names.add(node[1]) + elif node[0] in ("find", "logic", "ref"): + names |= set(_HOLE.findall(node[1])) + if node[0] == "find": + tables.add(node[1]) + return names, tables + + +def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]: + """식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로.""" + steps = {s["이름"]: s["식"] for s in row.get("중간", [])} + inputs = [s["이름"] for s in row.get("입력", [])] + seen, tables, todo = set(), [], list(exprs) + while todo: + names, found = _names(todo.pop()) + tables += [t for t in sorted(found) if t not in tables] + for name in names - seen: + seen.add(name) + if name in steps: + todo.append(steps[name]) + return [n for n in inputs if n in seen], tables + + +def _times(x: Decimal) -> str: + return format(x.normalize(), "f") + + +def _per_text(per: list[tuple[Decimal, str]]) -> str: + return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per)) + + +def _basis(master, tables: list[str], env: dict) -> dict | None: + for ref in tables: + try: + got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref)) + except (*_ERRORS, mf.EmptyInput): + continue + if got["갈래"] == "단위": + return got + return None + + +def _note(basis: dict | None, per: list, line: dict) -> str: + notes = [] + if basis and len(per) == 1: + times, one = per[0] + if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times: + ratio = basis["기준배"] / times + how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}" + notes.append( + f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}" + ) + goods = line.get("품목") or {} + if goods.get("단위") and line.get("단위"): + notes.append(mu.note(goods["단위"], line["단위"])) + return " · ".join(n for n in notes if n) + + +def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict: + uses, tables = _closure(row, exprs) + basis = _basis(master, tables, env) + out = { + "쓰는입력": uses, + "기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}" + if basis + else None, + "환산근거": _note(basis, per, line), + } + if bundle: + out["묶음"] = bundle[0] + if bundle[1]: + out["장비"] = True + return out + + +def annotate(master, key: str, given: dict, answer: dict) -> dict: + """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" + if not answer.get("ok") or "groups" not in answer: + return answer + row = master.logic(key) + per = mbs.result_per(row.get("결과단위")) + answer["기준단위"] = _per_text(per) or None + try: + env = mtx._env_of(master, row, given) + except _ERRORS: + env = {} + if "결과" in row: + for line in answer["groups"][0]["줄"]: + line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None)) + return answer + try: + result = mf.run(master, key, given) + except _ERRORS: + result = None + slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]} + items = row.get("호표") or [] + for n, item in enumerate(items): + line = result["줄"][n] if result else {} + if result: + parts = [p for p, _ in mtx._shares(line)] + elif item.get("종류") == "로직" and "비목" not in item: + parts = list(mf.COST_ITEMS) + else: + parts = [item.get("비목")] + exprs = [item.get("수량"), item.get("요소")] + got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + for part in parts: + target = next(slots.get(part, iter(())), None) + if target is not None: + target.update(got) + for extra in row.get("덧줄") or []: + target = next(slots.get(extra["비목"], iter(())), None) + if target is not None: + target.update( + _fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra)) + ) + return answer diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py new file mode 100644 index 000000000..f2266a8e5 --- /dev/null +++ b/resources/tester/test_714_1_sheet_line.py @@ -0,0 +1,62 @@ +"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와 +M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from M01_MasterData import M01_MasterData_Store as store # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402 + +msl = sheet.msl + + +def _text(key: str) -> dict: + auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None} + return sheet.text(key, auto) + + +def _lines(got: dict, groups: str = "groups") -> list[dict]: + return [line for g in got[groups] for line in g["줄"]] + + +def test_line_keys_and_bundle(): + got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄 + assert got["ok"] and got["기준단위"] == "㎥" + lines = _lines(got) + assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines) + machine = [line for line in lines if line.get("장비")] + assert machine and all(line["묶음"] == "경비" for line in machine) + assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"]) + assert any(line["묶음"] == "인력" for line in lines) + + +def test_uses_follow_middle_values(): + """중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀.""" + lines = _lines(_text("GF000219")) + assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"]) + + +def test_basis_note_tenfold(): + """표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음.""" + one = _lines(_text("GC000037")) + assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10" + ten = _text("GF000124") + assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten)) + + +def test_two_routes_same_shape(): + """M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음).""" + for key in ("GF000169", "GF000103", "GF000219"): + m01 = _lines(_text(key)) + m02 = _lines(standard.sheet(key, {}), "묶음") + assert len(m01) == len(m02) + for a, b in zip(m01, m02): + assert set(b) == {"이름", "글", "금액", *msl.KEYS} + assert a["이름"] == b["이름"] + assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS] From b62a1d935646c9fd1f6011f5695b6ad24971e788 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:31 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(715-3):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=A4=84=20=EB=8D=AE=EA=B0=9C(=ED=8C=A8=EC=B9=98)?= =?UTF-8?q?=20=C2=B7=20SK=20=EC=B0=B8=EC=A1=B0=20=EC=A4=84=20=C2=B7=20[?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5]=20=EA=B8=B8=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=8B=A8=EC=9C=84=20=EB=B9=88=EC=B9=B8=20=EB=A7=89?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=E3=80=8C1=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EB=8B=B9=E3=80=8D=20=EB=A8=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Labor_Patch.py — 줄 칸 패치 = SK 꼴 {덮개, 뺀줄, 더한줄}(한 벌) · 로직 줄 · SK 참조 줄만 · 줄 고름 · 자재표시(차례)를 cid 로 바꿔 뺀줄 뒤 제자리 · 셈 = 이름 없는 SK 로 M02_Sangeun_Calc - M02_Labor_Doc — clean 이 패치 정리 · calc 로직 줄 · 참조 줄 패치 갈래(몇 줄 · 690 줄) - M02_Labor_Expand — 사전조합 안 줄 패치 갈래 · 머리 「1 ㎡ 당」 - M02_Labor_Ref — 사전조합 줄에 패치 남김 · 줄 있는 일위대가 단위 빈칸 400(빈 새 문서는 받음 — 화면 새로 단추가 단위 null) · 409 글 종류 이름 사전조합 → 일위대가 · labor → 구조물 조합 - POST /api/m02/layers/{층}/sangeun/from-line(층 쓰기 권한) — 새 SK + 바꿀 참조 줄 · 셈 답에 머리 - Labor_Calc.ts 짝은 그대로(settle 은 서버 기준을 받음 · 거울 시험 통과) - 낡은 시험 고침: test_m02_labor_ref · test_175_precombo_mats · test_204_2_b09_bill_library 의 줄 있는 사전조합에 단위(빈칸 막기) · 참조 줄 분모 = 그 사전조합 단위 - 새 시험 test_715_3_labor_patch.py(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Doc.py | 8 +- M02_MasterTemplete/M02_Labor_Expand.py | 26 +++- M02_MasterTemplete/M02_Labor_Patch.py | 123 ++++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 12 +- M02_MasterTemplete/M02_Sangeun_Calc.py | 24 +++- M02_MasterTemplete/M02_Sangeun_Router.py | 21 ++- resources/tester/test_175_precombo_mats.py | 14 +- .../tester/test_204_2_b09_bill_library.py | 2 +- resources/tester/test_715_3_labor_patch.py | 134 ++++++++++++++++++ resources/tester/test_715_sangeun_store.py | 9 +- resources/tester/test_m02_labor_ref.py | 12 +- 11 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Patch.py create mode 100644 resources/tester/test_715_3_labor_patch.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index dff01a191..71d496dd0 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -42,6 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]: "별칭": alias, "메모": str(memo) if memo else None, "수량": _qty(one.get("수량"), where), + **patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3) } ) continue @@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]: **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), **({"승격": True} if one.get("승격") is True else {}), + **patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3) **( {"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {} ), @@ -512,7 +515,7 @@ def calc( lines = [] for one in doc.get("담은로직") or []: if _is_ref(one): - lines.append(ref_line(one["참조"], expand(one["참조"]))) + lines.append(ref_line(one["참조"], patch.ref(one, expand))) continue if fixed.is_fixed(one): lines.append(fixed.line(whole, one[fixed.FIXED])) @@ -525,6 +528,9 @@ def calc( "분모": pill.get("단위"), "배": pill.get("배") or 1, } + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지 + lines.append({**line, **patch.run(whole, one, read)}) + continue got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 35a73300c..10054f5c7 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -11,6 +11,7 @@ from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -81,10 +82,13 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line( - self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") - ) - got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + got = patch.run(self.whole, one, self.read) + else: + got = labor.run_line( + self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) + got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -109,9 +113,10 @@ class Expand: inner, rows, why = [], [], None for one in lines: ref = one.get("참조") - if isinstance(ref, dict): - head, sub = {"참조": ref}, self(ref, trail) - elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) + if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 + head = {"참조": ref} + sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self) + elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음 head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"])) elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`) head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) @@ -156,7 +161,14 @@ class Expand: return { "이름": doc.get("이름"), "단위": doc.get("단위"), + "머리": head_of(doc), "기준": None if why else labor._sum(rows), "까닭": why, "안": inner, } + + +def head_of(doc: dict) -> str | None: + """상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None.""" + unit = str(doc.get("단위") or "").strip() + return f"1 {unit} 당" if unit else None diff --git a/M02_MasterTemplete/M02_Labor_Patch.py b/M02_MasterTemplete/M02_Labor_Patch.py new file mode 100644 index 000000000..aa0b3db09 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Patch.py @@ -0,0 +1,123 @@ +"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5). + +줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 · +로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 · +줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리. +더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로). +셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는 +알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +FIELD = "패치" +KEEP = ("덮개", "뺀줄", "더한줄") +TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함 + + +def _bad(text: str): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return store.StoreError(400, text) + + +def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict: + """줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400.""" + raw = one.get(FIELD) + if not raw: + return {} + if not isinstance(raw, dict): + raise _bad(f"{where} · 패치는 묶음") + if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)): + raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로") + body = {k: raw.get(k) for k in KEEP} + try: + got = sd.clean( + {"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole + ) + except sd.DocError as e: + raise _bad(f"{where} · {e}") from e + out = {k: got[k] for k in KEEP if got[k]} + return {FIELD: out} if out else {} + + +def ref_clean(one: dict, ref: dict, where: str) -> dict: + """단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + if not one.get(FIELD): + return {} + if refs.kind_of(ref["키"]) != sd.KIND: + raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만") + from M01_MasterData import M01_MasterData_Store as m01 + + doc = refs.read_ref(ref) or {} + return clean(one, where, m01.loaded()[1], None, doc.get("열린입력")) + + +def _keyed(value: Any, ids: list[str]) -> dict: + """차례 열쇠 → cid 열쇠(바탕 줄 차례).""" + out = {} + for n, v in (value or {}).items() if isinstance(value, dict) else (): + at = int(n) if str(n).isdigit() else -1 + if 0 <= at < len(ids): + out[ids[at]] = v + return out + + +def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]: + """일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로.""" + body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP} + head = {"키": TEMP, "이름": name, "단위": unit, **body} + if "참조" in one: + return {**head, "바탕": {"참조": one["참조"]}} + try: + ids = sd.line_ids(whole.logic(one["로직"])) + except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭 + ids = [] + extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {} + return { + **head, + **extra, + "바탕": {"로직": one["로직"]}, + "입력값": dict(one.get("입력값") or {}), + "고름": _keyed(one.get("고름"), ids), + "자재표시": _keyed(one.get("자재표시"), ids), + } + + +def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]: + """패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + doc = sd.clean(as_doc(one, whole), None, whole) + return calc.run(doc, whole, None, read or refs.read_ref) + except sd.DocError as e: + return {"기준": None, "까닭": str(e)} + + +def ref(one: dict, expand: Any) -> dict[str, Any]: + """참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not one.get(FIELD): + return expand(one["참조"]) + doc = as_doc(one, expand.whole) + base = expand.read(one["참조"]) or {} + doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]} + return calc.unit(doc, expand.whole, expand, ()) + + +def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]: + """[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버).""" + if "로직" not in one and "참조" not in one: + raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장") + doc = as_doc(one, whole, name, unit) + doc.pop("키") + return doc diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ae47ac4be..7bf6f9943 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out: dict[str, Any] = {"종류": TITLE[kind], "키": key} if not name: raise _bad("사전조합 이름이 빔") - out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + lines = _combo_lines(doc, key) + if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) · + raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음 + out.update(이름=name, 단위=unit or None, 담은로직=lines) out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None @@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: lines = [] for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: - head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]} + head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: - head["입력값"] = one["입력값"] + head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) @@ -318,7 +321,8 @@ def users(ref: Ref) -> list[str]: """`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌).""" rid, out = ref_id(ref), [] - for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")): + # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」 + for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")): for at, path in layers.layer_files(kind): try: doc = json.loads(path.read_bytes()) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 0ea7b5e10..a6f0743fd 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -170,6 +170,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} +def head_of(doc: dict) -> str: + return f"1 {doc.get('단위')} 당" + + # ── 저장 ────────────────────────────────────────────────────────────── def _check_refs(doc: dict, here_: refs.Here) -> None: me = {"층": here_[0], "주인": here_[1], "키": doc["키"]} @@ -227,4 +231,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: got = run(clean, whole, given, trail=(me,)) except sd.DocError as e: raise _fail(e) from e - return labor._json(got) + return labor._json({**got, "머리": head_of(clean)}) + + +def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: + """[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK · + 답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Labor_Patch as patch + + if not isinstance(line, dict): + raise store.StoreError(400, "줄이 묶음 아님") + doc = patch.to_sangeun(line, name, unit, m01.loaded()[1]) + made = refs.create(sd.KIND, doc) + at = refs.here() + keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line} + return { + "이름": made["이름"], + "줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep}, + } diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index bbc516045..72d0a415e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,4 +1,5 @@ -"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`. +"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · +[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). @@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]: return calc.calc_doc(doc, body.입력값) +class FromLineBody(BaseModel): + 줄: dict[str, Any] + 이름: str + 단위: str + + +@router.post("/layers/{layer}/sangeun/from-line") +def sangeun_from_line( + layer: Layer, + body: FromLineBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, calc.from_line, body.줄, body.이름, body.단위) + + @router.post("/layers/{layer}/sangeun/calc") def calc_sangeun( layer: Layer, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 248695cfd..3688f2103 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None} def _save(folder, doc): with store.at(folder): - key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"] return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) @@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811 # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): - key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ - "이름" - ] + key = refs.create( + "precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats} + )["이름"] stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) return key, stone + _price(MAT) * 2 @@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F with store.at(world["system"]): outer = refs.create( "precombo", - {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + { + "이름": "겹", + "단위": "㎡", + "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], + }, )["이름"] got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index e0cfa1ebf..1a6a9a5c6 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch): company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): - other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py new file mode 100644 index 000000000..23b87f316 --- /dev/null +++ b/resources/tester/test_715_3_labor_patch.py @@ -0,0 +1,134 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. + +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘 +COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}} + + +def _whole(): + return m01.loaded()[1] + + +def _sc(folder, lines, unit="㎡"): + with store.at(folder): + return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"] + + +def _value(folder, key): + with store.at(folder): + return refs.Expand(_whole())({**MINE, "키": key}) + + +def test_logic_line_cover_in_precombo(world): + """사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄).""" + bare = labor.run_line(_whole(), PCT, {}) + sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}]) + with store.at(world["personal"]): + kept = store.read("precombo", sc)["문서"]["담은로직"][0] + assert kept["패치"] == COVER + got = _value(world["personal"], sc)["기준"] + labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal( + str(bare["줄"][1]["금액"]) + ) + assert got["노무비"] == pytest.approx(labor_sum) + assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1) + + +def test_line_pick_by_order_stays_after_drop(world): + """줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + want = mp.search(whole, item["요소"], {}, item.get("단위"))[1] + one = { + "로직": TWO_MATS, + "고름": {"2": want}, + "자재표시": {"2": {"별도계상": "직접계산"}}, + "패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]}, + } + with store.at(world["personal"]): + doc = labor.clean({"담은로직": [one]}) + got = labor.calc(doc, {})["줄"][0] + picked = [x for x in got["줄"] if x.get("품목")] + assert [x["품목"]["키"] for x in picked] == [want] + + +def test_ref_line_cover_only_that_line(world): + """SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로.""" + with store.at(world["personal"]): + sk = refs.create( + "sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + before = store.read("sangeun", sk)["판"] + ref = {**MINE, "키": sk} + sc = _sc( + world["personal"], + [{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}], + ) + inner = _value(world["personal"], sc)["안"] + plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"] + covered = calc.calc_doc( + {"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER} + ) + assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"]))) + assert inner[0]["기준"]["계"] != plain["계"] + with store.at(world["personal"]): + assert store.read("sangeun", sk)["판"] == before + + +def test_save_as_sangeun(world): + """[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값.""" + one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"} + with store.at(world["personal"]): + line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"] + got = calc.from_line(one, "줄로 만든", "㎡") + doc = store.read("sangeun", got["이름"])["문서"] + value = calc.calc_doc(doc)["기준"] + assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"} + assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5 + assert value["계"] == pytest.approx(line_value["계"]) + + +def test_refusals(world): + with store.at(world["personal"]): + with pytest.raises(store.StoreError, match="단위가 빔"): + refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]}) + assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"] + bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}} + with pytest.raises(store.StoreError, match="참조 못 넣음"): + labor.clean({"담은로직": [bad]}) + with pytest.raises(store.StoreError, match="수가 아님"): + labor.clean( + {"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]} + ) + sc = _sc(world["personal"], [{"로직": PCT}]) + with pytest.raises(store.StoreError, match="줄 덮개는"): + labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]}) + + +def test_no_patch_same_value(world): + """덮개 없는 줄 = run_line 값 그대로(옛 셈 길).""" + sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}]) + with store.at(world["personal"]): + assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0] + assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx( + labor.run_line(_whole(), PCT, {})["기준"]["계"] + ) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 753028f25..0debcf8d0 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,3 +1,4 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 """M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · @@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world): with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]}, + { + "이름": "조합", + "단위": "㎡", + "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], + }, )["이름"] got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) with pytest.raises(store.StoreError) as e: store.delete("sangeun", sk) one = _calc(world["personal"], sk)["1단위"]["계"] assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) - assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail) + assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 7f96f4ad3..144178dde 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra): def _combo(folder, lines, name="조합"): with store.at(folder): - return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] + return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"] def _read(folder, kind, key): @@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 one = got["줄"][0] want = Decimal(str(_base(TEN)["계"])) / 10 * 2 assert one["기준"]["계"] == pytest.approx(float(want)) - assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref)) + assert (one["분모"], one["배"], one["알약"]) == ( + "㎡", + 1, + refs.ref_id(ref), + ) # 단위 = 그 사전조합 것 assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) - assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] + assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}] def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): @@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world): } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" - body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}} + body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}} got = client.post(url.format("personal", "precombo"), json=body).json() assert got["이름"] == "SC-000001" assert client.post(url.format("system", "precombo"), json=body).status_code == 403 From 458569046566408ac6ea4cd809fab25851ee4cc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:57 +0900 Subject: [PATCH 5/8] =?UTF-8?q?style(715-3):=20715=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EA=B8=80=20=EC=A4=84=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20=C2=B7=20fixture=20=EB=B0=9B=EC=95=84=20=EC=94=80=20noqa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_3_labor_patch.py | 7 ++++--- resources/tester/test_715_sangeun_store.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py index 23b87f316..b6b4de67a 100644 --- a/resources/tester/test_715_3_labor_patch.py +++ b/resources/tester/test_715_3_labor_patch.py @@ -1,8 +1,9 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage. -줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · -[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · +줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값. """ from decimal import Decimal diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 0debcf8d0..05be42e2d 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,5 +1,5 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. +"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · 순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. @@ -47,7 +47,7 @@ def _bare(code): def test_qty_cover_hand_and_pct_recomputed(world): - """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값.""" + """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈.""" bare = _bare(PCT) first, second = bare["줄"][0], bare["줄"][1] key = _sk( From fdd781ca465315d323e55d5e9225d7576cdb9ee9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:58 +0900 Subject: [PATCH 6/8] auto: 2026-11-01 16:04 (ESD_LAPTOP) --- resources/tester/test_715_sangeun_engine.py | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py new file mode 100644 index 000000000..e0c01a8e3 --- /dev/null +++ b/resources/tester/test_715_sangeun_engine.py @@ -0,0 +1,84 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. + +줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 +다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) + + +def _whole(): + return m01.loaded()[1] + + +def _run(**doc): + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + whole = _whole() + clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) + return calc.run(clean, whole) + + +def test_price_cover_is_hand_price_and_pct_follows(): + bare = labor.run_line(_whole(), PCT, {}) + first, second = bare["줄"][0], bare["줄"][1] + got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) + line = got["줄"][0] + assert line["단가"] == 200000 and line["출처"] == "손 단가" + want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(want)) + assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) + + +def test_bundle_cover_on_logic_line_partial(): + bare = labor.run_line(_whole(), SUB, {}) + sub = bare["줄"][2] + got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) + line = got["줄"][2] + qty = Decimal(str(sub["수량"])) + assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) + assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 + + +def test_untouched_lines_same_as_engine(): + bare = labor.run_line(_whole(), SUB, {}) + got = _run(바탕={"로직": SUB}) + assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) + + +def test_added_ref_and_fixed_lines(world): + """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" + with store.at(world["personal"]): + sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ + "이름" + ] + unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + top = refs.create( + "sangeun", + { + "이름": "위", + "단위": "식", + "바탕": None, + "더한줄": [ + {"참조": {**MINE, "키": sk}, "수량": 2}, + {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, + ], + }, + )["이름"] + got = calc.calc_doc(store.read("sangeun", top)["문서"]) + assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) + expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] + assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From dfa72ac29248562e44beff3b369cecc8644865bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:05:18 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test(715-1b):=20auto=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=97=90=20=EC=93=B8=EB=A0=A4=20=EC=98=AC=EB=9D=BC=EA=B0=84=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EB=8D=A7=EC=B9=B8=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=84=20=EB=BA=8C=20=E2=80=94=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EB=8D=A7=EC=B9=B8(sub3=20712=20=EB=92=A4)=20=EC=A0=84=EC=97=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=C2=B7=20715-1b=20=EB=95=8C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=84=A3=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_sangeun_engine.py | 84 --------------------- 1 file changed, 84 deletions(-) delete mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py deleted file mode 100644 index e0c01a8e3..000000000 --- a/resources/tester/test_715_sangeun_engine.py +++ /dev/null @@ -1,84 +0,0 @@ -# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. - -줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 -다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. -""" - -from decimal import Decimal - -import pytest - -from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Labor_Doc as labor -from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete import M02_Sangeun_Calc as calc -from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 - -PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 -SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) - - -def _whole(): - return m01.loaded()[1] - - -def _run(**doc): - from M02_MasterTemplete import M02_Sangeun_Doc as sd - - whole = _whole() - clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) - return calc.run(clean, whole) - - -def test_price_cover_is_hand_price_and_pct_follows(): - bare = labor.run_line(_whole(), PCT, {}) - first, second = bare["줄"][0], bare["줄"][1] - got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) - line = got["줄"][0] - assert line["단가"] == 200000 and line["출처"] == "손 단가" - want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) - assert got["기준"]["노무비"] == pytest.approx(float(want)) - assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) - - -def test_bundle_cover_on_logic_line_partial(): - bare = labor.run_line(_whole(), SUB, {}) - sub = bare["줄"][2] - got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) - line = got["줄"][2] - qty = Decimal(str(sub["수량"])) - assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) - assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 - - -def test_untouched_lines_same_as_engine(): - bare = labor.run_line(_whole(), SUB, {}) - got = _run(바탕={"로직": SUB}) - assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) - - -def test_added_ref_and_fixed_lines(world): - """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" - with store.at(world["personal"]): - sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ - "이름" - ] - unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] - top = refs.create( - "sangeun", - { - "이름": "위", - "단위": "식", - "바탕": None, - "더한줄": [ - {"참조": {**MINE, "키": sk}, "수량": 2}, - {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, - ], - }, - )["이름"] - got = calc.calc_doc(store.read("sangeun", top)["문서"]) - assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) - expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] - assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From 36a21591e264388e8b5f515d31da5367e04c2559 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:11:09 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(715=20=C2=B7=20712-2=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C):=20M02=5FSangeun=5FCalc.times=5Fof=20=E2=80=94=20?= =?UTF-8?q?=EC=98=9B=20Store=5FCombo.=5FPER=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=95=9C=20=EB=B2=8C=20master=5Funit.per(?= =?UTF-8?q?712-2=20=EA=B0=80=20=EC=98=AE=EA=B9=80)=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EC=B9=9C=20=EB=92=A4=20=EC=8B=9C=ED=97=98=2063=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index a6f0743fd..a45cd402c 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -47,9 +47,8 @@ def times_of(row: dict) -> Decimal: """결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store - per = combo_store._PER.findall(str(row.get("결과단위") or "")) - head = per[0][0].replace(",", "") if len(per) == 1 else "" - return Decimal(head) if head else Decimal(1) + per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌 + return per[0][0] if len(per) == 1 else Decimal(1) def fingerprint(row: dict) -> str: