From 3e3bb8ef09523d31349f9f7dabc6ec59431f8fc9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 22 Sep 2026 13:53:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20PLAN=207-4b=20=EB=92=A4=20?= =?UTF-8?q?=EC=A0=88=EB=B0=98=20=E2=91=A2=20=E2=80=94=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=A1=B0=EA=B1=B4=EC=97=90=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=C2=B7=EA=B3=84=EC=95=BD=EC=A2=85=EB=B3=84=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=84=93=ED=98=80=20=EB=AA=A8=EB=9E=98=C2=B7?= =?UTF-8?q?=EC=9E=90=EA=B0=88=C2=B7=EC=A0=84=EB=A0=A5=2017=EC=A4=84?= =?UTF-8?q?=EC=9D=84=20=ED=92=88=EC=85=88=EC=9E=AC=EB=A3=8C=20=EA=B0=84?= =?UTF-8?q?=EC=A0=91=20=EC=97=86=EC=9D=B4=20=ED=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master_material.py — PICK_KEYS 에 「지역」·「계약종별」 더함. `_region`·`_tariff` 두 함수를 품셈재료 행이 아니라 조건 묶음 자체를 받게 고쳐 옛길(품셈재료 「연결」 간접)과 새길(호표 「요소」 에 조건을 바로 박음) 이 같은 코드를 씀(sub3 가 분석해 둔 방식 그대로 — 이름 뿌리 endswith·단위 같음·지역 이름 포함 매칭). check_pick — 지역·계약종별 조건은 구분 없이 이름만 확인 · 값이 "입력" 아닌 채 섞이면 틀로 잡음. build_로직_재료_고르기.py — 품셈재료 연결이 {이름·지역:입력} 이나 {이름·계약종별:입력} 인 줄(모래 MP000004·자갈 MP000006·전력 MP000358)도 그 조건 묶음을 그대로 호표에 박도록 넓힘 — 다시 돌려 로직 4개 파일 17줄 반영(모래 8·자갈 1·전력 8). 전력은 지시대로 재료_유가전력.json(MO 테이블)을 그대로 가리킴 — 자재품목은 안 거침. test_masterdata_check.py — PICK_KEYS 늘어난 만큼 cond_text 글자 열쇠 시험 고침 · 지역 값이 "입력" 아닌 조건은 틀로 잡히는 것 확인. test_m01_api.py — 전력 계약종별 시험을 mf.element 직접 호출로 바꿈(화면 단가 미리보기 `_pick_brief`(M01_MasterData_Store.py, 담당 밖)는 구분·상세구분 자리로만 찾아 지역·계약종별 조건은 아직 못 다뤄 None — 아는 구멍으로 남김 · 계산 자체는 정상 확인). typecheck 통과 · 관련 시험 108 통과 · 로직 1354개 전수 대조 금액 달라짐 0·새 멈춤 0. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT --- .../scripts/build_로직_재료_고르기.py | 16 +++-- .../master_data/scripts/master_material.py | 60 +++++++++++++------ .../로직_건설품셈_06장_철근콘크리트공사.json | 2 +- .../로직_건설품셈_09장_미장공사.json | 4 +- .../로직_건설품셈_13장_플랜트설비공사.json | 22 +++---- .../로직_산림품셈_12장_철근콘크리트.json | 6 +- resources/tester/test_m01_api.py | 12 +++- resources/tester/test_masterdata_check.py | 4 +- 8 files changed, 81 insertions(+), 45 deletions(-) diff --git a/resources/master_data/scripts/build_로직_재료_고르기.py b/resources/master_data/scripts/build_로직_재료_고르기.py index 2effc236..fe3cccf3 100644 --- a/resources/master_data/scripts/build_로직_재료_고르기.py +++ b/resources/master_data/scripts/build_로직_재료_고르기.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- """로직 호표의 재료 줄을 「고르기 조건」 으로 바꿈 — 요소 「MP……」·「MT……」 를 조건 묶음으로. - · 바탕 셋 — + · 바탕 넷 — ① 재료_품셈재료.json 의 「고르기」 칸 (`scripts/build_품셈재료_고르기.py` 가 채움) ② 자재품목 줄을 바로 가리키는 「MT……」 — 그 줄의 구분·상세구분·규격 + 대표 = 그 줄 ③ 연결이 자재품목 키 하나인 품셈재료 「MP……」 — 그 자재품목 줄로 ② 와 같게 + ④ 연결이 지역·계약종별 조건 묶음(모래 · 자갈 · 전력)인 품셈재료 — 그 묶음을 호표에 + 그대로 박음(PLAN 7-4b 뒤 절반 · `master_material._region`·`_tariff` 가 품셈재료 + 간접 없이 바로 풂) ②③ 은 대표 줄이 옛 요소와 같은 줄이라 금액이 그대로임(고르기 화면만 열림) - · 고르기 칸이 비고 연결도 지역·계약종별을 계산 때 푸는 줄(모래 · 자갈 · 전력)은 옛 방식 그대로 둠 · 줄 텍스트의 「"요소": "MP……"」 한 줄만 바꿈 — 나머지 글자·차례 그대로 · 다시 돌려도 같은 결과(이미 바뀐 줄은 건드릴 것이 없음) @@ -38,12 +40,18 @@ def _cond_of(row: dict) -> dict | None: def _picks() -> dict[str, dict]: - """요소 키 → 고르기 조건 — 품셈재료 고르기 칸 · 자재품목 줄 · 연결이 키 하나인 품셈재료.""" + """요소 키 → 고르기 조건 — 품셈재료 고르기 칸 · 자재품목 줄 · 연결이 키 하나인 품셈재료 · + 연결이 지역·계약종별 조건 묶음인 품셈재료(그 묶음을 그대로 호표에 박음 — PLAN 7-4b 뒤 절반).""" market = {str(r["키"]): r for r in json.loads(MARKET.read_text(encoding="utf-8"))["줄"]} picks = {key: cond for key, row in market.items() if (cond := _cond_of(row))} for row in json.loads(SRC.read_text(encoding="utf-8"))["줄"]: link = row.get("연결") - cond = row.get("고르기") or (picks.get(link) if isinstance(link, str) else None) + if isinstance(link, str): + cond = row.get("고르기") or picks.get(link) + elif isinstance(link, dict) and (link.get("지역") == "입력" or link.get("계약종별") == "입력"): + cond = link + else: + cond = row.get("고르기") if cond: picks[str(row["키"])] = cond return picks diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 987d8822..2230825e 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -16,7 +16,7 @@ LINKED = "MP" # 품셈재료 테이블ID MARKET = "MT" # 자재품목 테이블ID PRICED = "MO" # 유가전력 테이블ID — 값 한 칸 SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열 -PICK_KEYS = ("구분", "상세구분", "이름", "규격", "대표") # 재료 고르기 조건 칸 (`_틀.md` 7장) +PICK_KEYS = ("구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별") # 재료 고르기 조건 칸 (`_틀.md` 7장) REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀 TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음 _UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} @@ -64,11 +64,11 @@ def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]: return master._market -def _tariff(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]: - """후보 조건{이름 · 계약종별: 입력} — 유가전력 전력 줄 가운데 그 계약종별·이름으로 시작하는 줄.""" - cond = row["연결"] +def _tariff(master: mf.Master, label_ref, cond: dict, env: dict) -> list[str]: + """후보 조건{이름 · 계약종별: 입력} — 유가전력 전력 줄 가운데 그 계약종별·이름으로 시작하는 줄. + `label_ref` 는 알림 글에 쓸 자리(호표 줄의 고르기 조건 · 또는 품셈재료 옛 키).""" if TARIFF not in env: - raise mf.FormulaError(f"「{master.label(ref)}」 입력 「{TARIFF}」 없음") + raise mf.FormulaError(f"「{master.label(label_ref)}」 입력 「{TARIFF}」 없음") want, kind = plain(cond["이름"]), plain(env[TARIFF]) hits = [ key @@ -79,29 +79,26 @@ def _tariff(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]: and one.get("값") is not None ] if not hits: - raise mf.FormulaError(f"「{master.label(ref)}」 계약종별 값 없음 — {env[TARIFF]}") + raise mf.FormulaError(f"「{master.label(label_ref)}」 계약종별 값 없음 — {env[TARIFF]}") return hits -def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]: +def _region(master: mf.Master, label_ref, cond: dict, env: dict, unit) -> list[str]: """후보 조건{이름 · 규격 · 지역: 입력} — 뿌리가 이름으로 끝나고 이름에 지역 글자 · 단위 같음.""" - cond = row["연결"] - if cond.get("계약종별") == "입력": - return _tariff(master, ref, row, env) if cond.get("지역") == "입력" and REGION not in env: - raise mf.FormulaError(f"「{master.label(ref)}」 입력 「{REGION}」 없음") + raise mf.FormulaError(f"「{master.label(label_ref)}」 입력 「{REGION}」 없음") region = plain(env.get(REGION, "")) if cond.get("지역") == "입력" else "" - want, spec, unit = plain(cond["이름"]), plain_spec(cond.get("규격")), plain_unit(row["단위"]) + want, spec, unit_p = plain(cond["이름"]), plain_spec(cond.get("규격")), plain_unit(unit) hits = [ (s, key) for root, name, s, u, key in _market_index(master) - if root.endswith(want) and region in name and u == unit and (not spec or s == spec) + if root.endswith(want) and region in name and u == unit_p and (not spec or s == spec) ] hits = [(s, key) for s, key in hits if _offers(master, key)] if not hits: - raise mf.FormulaError(f"「{master.label(ref)}」 지역 값 없음 — {env.get(REGION)}") + raise mf.FormulaError(f"「{master.label(label_ref)}」 지역 값 없음 — {env.get(REGION)}") if len({s for s, _ in hits}) > 1: - raise mf.FormulaError(f"「{master.label(ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)") + raise mf.FormulaError(f"「{master.label(label_ref)}」 후보 여럿 — 규격 선택 필요 ({len(hits)}줄)") return [key for _, key in hits] @@ -183,8 +180,18 @@ def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> l def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]: """고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0. - 값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌.""" - if set(cond) - set(PICK_KEYS) or not cond.get("구분"): + 값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌. + 지역·계약종별 조건은 구분 없이 이름 하나로 자재품목·유가전력을 넓게 찾음(_region · _tariff) + — 여기서는 이름 칸만 있는지 봄(후보 0 은 계산 때 판단).""" + if set(cond) - set(PICK_KEYS): + return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"] + if ("지역" in cond and cond["지역"] != "입력") or ( + "계약종별" in cond and cond["계약종별"] != "입력" + ): + return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — 지역·계약종별은 늘 「입력」"] + if cond.get("지역") == "입력" or cond.get("계약종별") == "입력": + return [] if cond.get("이름") else [f"{where} · 재료 고르기 조건 「{cond}」 — 이름 없음"] + if not cond.get("구분"): return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"] cond = static(cond) if not cond.get("구분"): # 구분 자체가 `{입력}` — 계산 때 채워져 여기서 좁힐 것이 없음 @@ -207,7 +214,17 @@ def element( master: mf.Master, ref, env: dict, unit: str | None = None ) -> tuple[object, str | None]: """요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`. - `ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂.""" + `ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂. + 조건에 지역·계약종별 이 「입력」 이면 구분·상세구분 없이 이름 하나로 넓게 찾음(_region · _tariff) + — 호표 줄에 바로 써도 품셈재료 간접 없이 풀림(PLAN 7-4b 뒤 절반).""" + if isinstance(ref, dict) and (ref.get("계약종별") == "입력" or ref.get("지역") == "입력"): + keys = ( + _tariff(master, ref, ref, env) + if ref.get("계약종별") == "입력" + else _region(master, ref, ref, env, unit) + ) + offers = [x for key in keys for x in _offers(master, key)] + return min(offers, key=lambda x: x[0]) if offers else (None, None) if isinstance(ref, dict): ref = pick(master, ref, env, unit) row = master.get(ref) @@ -218,7 +235,12 @@ def element( link = row.get("연결") if not link: return None, None - keys = [link] if isinstance(link, str) else _candidates(master, ref, row, env) + if isinstance(link, str): + keys = [link] + elif link.get("계약종별") == "입력": + keys = _tariff(master, ref, link, env) + else: + keys = _region(master, ref, link, env, row["단위"]) offers = [x for key in keys for x in _offers(master, key)] return min(offers, key=lambda x: x[0]) if offers else (None, None) diff --git a/resources/master_data/로직_건설품셈_06장_철근콘크리트공사.json b/resources/master_data/로직_건설품셈_06장_철근콘크리트공사.json index 8056f925..29c1a31e 100644 --- a/resources/master_data/로직_건설품셈_06장_철근콘크리트공사.json +++ b/resources/master_data/로직_건설품셈_06장_철근콘크리트공사.json @@ -1189,7 +1189,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "구멍땜 모래", "단위": "㎥", "수량": "구멍땜 * 찾기(QC000430).모래 / 100", diff --git a/resources/master_data/로직_건설품셈_09장_미장공사.json b/resources/master_data/로직_건설품셈_09장_미장공사.json index 4064c0f7..cde52acd 100644 --- a/resources/master_data/로직_건설품셈_09장_미장공사.json +++ b/resources/master_data/로직_건설품셈_09장_미장공사.json @@ -60,7 +60,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "찾기(QC001347, 배합용적비=배합용적비).모래", @@ -533,7 +533,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "찾기(QC001358, 구분='모래').수량", diff --git a/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json b/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json index a8211ff5..07def85b 100644 --- a/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json +++ b/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json @@ -2415,7 +2415,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "찾기(QC001638, '두께(㎜)'=두께, '자세 및 직종'=자세, 구분='소요전력(㎾h)').수량", @@ -2521,7 +2521,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "찾기(QC001641, '두께(㎜)'=두께, '자세 및 직종'=자세, 구분='전력소비량(㎾h)').수량", @@ -2635,7 +2635,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "찾기(QC001642, '두께(㎜)'=두께, '자세 및 직종'=자세, 구분='소요전력(㎾h)').수량", @@ -2744,7 +2744,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "찾기(QC001639, '자세 및 직종 두께(㎜)'=두께, 구분=만약(용접면 == '하향한면', '하향한면 용접', '하향양면 용접')).'소요전력(㎾h)'", @@ -2853,7 +2853,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "찾기(QC001640, '자세 및 직종 두께(㎜)'=두께, 구분=만약(용접면 == '하향한면', '하향한면 용접', '하향양면 용접')).'소요전력(㎾h)'", @@ -8736,7 +8736,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "0.262", @@ -8763,7 +8763,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "370", @@ -9041,7 +9041,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "0.242", @@ -9068,7 +9068,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "310", @@ -10289,7 +10289,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "0.242", @@ -10316,7 +10316,7 @@ }, { "종류": "재료", - "요소": "MP000358", + "요소": {"이름": "전력량요금", "계약종별": "입력"}, "이름": "전력", "단위": "㎾h", "수량": "306", diff --git a/resources/master_data/로직_산림품셈_12장_철근콘크리트.json b/resources/master_data/로직_산림품셈_12장_철근콘크리트.json index 12877f82..f1de287f 100644 --- a/resources/master_data/로직_산림품셈_12장_철근콘크리트.json +++ b/resources/master_data/로직_산림품셈_12장_철근콘크리트.json @@ -2499,7 +2499,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래부설(또는 양질토사)", "단위": "㎥", "수량": "모래량", @@ -3944,7 +3944,7 @@ }, { "종류": "재료", - "요소": "MP000004", + "요소": {"이름": "모래", "규격": "", "지역": "입력"}, "이름": "모래", "단위": "㎥", "수량": "찾기(QF000385, 배합비=배합비).모래", @@ -3952,7 +3952,7 @@ }, { "종류": "재료", - "요소": "MP000006", + "요소": {"이름": "자갈", "규격": "", "지역": "입력"}, "이름": "자갈", "단위": "㎥", "수량": "찾기(QF000385, 배합비=배합비).자갈", diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index e85d7485..5463b069 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -384,11 +384,17 @@ def test_재료_고르기_줄_단가는_대표_자재품목의_낮은_값(client def test_전력은_계약종별을_골라야_값이_섬(client: TestClient) -> None: - """후보 조건 「계약종별: 입력」 — 화면에선 값 없음 · 계산 때 로직 입력으로 정해짐.""" + """후보 조건 「계약종별: 입력」(PLAN 7-4b 뒤 절반 — 품셈재료 간접 없이 호표에 바로 박음) — + 계산은 로직 입력으로 값이 섬(mf.element 로 직접 확인). 화면 단가 미리보기(_pick_brief)는 + 구분·상세구분 자리로만 후보를 찾아 지역·계약종별 조건은 아직 못 다뤄 None — 아는 구멍.""" one = _get(client, "/api/m01/logic", key=key_of("GC000893")) - power = one["prices"][key_of("MP:전력")] - assert power["값"] is None and power["출처"] is None + cond = {"이름": "전력량요금", "계약종별": "입력"} + assert one["prices"][store.cm.mf.cond_text(cond)] is None assert "전력 계약종별" in {x["이름"] for x in one["logic"]["입력"]} + with pytest.raises(store.cm.mf.FormulaError, match="전력 계약종별"): + store.cm.mf.element(store.cm.master(), cond, {}) + value, source = store.cm.mf.element(store.cm.master(), cond, {"전력 계약종별": "주택용 저압"}) + assert value is not None and source def test_하위_거름과_참조_이름(client: TestClient) -> None: diff --git a/resources/tester/test_masterdata_check.py b/resources/tester/test_masterdata_check.py index f604f1b3..654c9f4b 100644 --- a/resources/tester/test_masterdata_check.py +++ b/resources/tester/test_masterdata_check.py @@ -951,8 +951,8 @@ def test_품셈재료_고르기_칸_검사(): def test_고르기_조건_글자_열쇠(): """화면 단가 미리보기가 조건 줄을 찾는 이름 — 빈 칸은 빈 글자.""" - assert mm.cond_text({"구분": "관·수로", "상세구분": "흄관"}) == "관·수로|흄관|||" - assert mm.cond_text({"구분": "레미콘", "대표": "MT900002"}) == "레미콘||||MT900002" + assert mm.cond_text({"구분": "관·수로", "상세구분": "흄관"}) == "관·수로|흄관|||||" + assert mm.cond_text({"구분": "레미콘", "대표": "MT900002"}) == "레미콘||||MT900002||" def test_품셈재료_고르기_칸이_실파일에_있음():