diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 2f2fe682..e8fed13c 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -47,6 +47,7 @@ _ZERO = Decimal(0) # ⬇ 인계 읽기는 `_Input` 으로 옮겼다(2026-09-15 700줄 분리) — 쓰던 이름이 그대로 살게 다시 내보낸다. from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402,F401 + BLOCKED_LINK_UNDECIDED, BLOCKED_UNCONFIRMED, SUPPLY_OWNER, SUPPLY_UNKNOWN, @@ -56,6 +57,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( # noqa: E402 _decimal, parse_handoff, ) +from common_util.common_util_work_item_link import undecided_links # noqa: E402 #: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋). #: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것. @@ -75,6 +77,7 @@ _BLOCKED_LABELS = { "input_missing": "입력이 필요합니다", "unit_data_missing": "원단위가 없습니다(우리가 만들 것)", "formula_missing": "전개식이 없습니다(우리가 만들 것)", + BLOCKED_LINK_UNDECIDED: "연결고리 미판정 — 금액에 안 들어감", } @@ -353,6 +356,18 @@ def build_bill( } ) continue + if item.blocked_kind == BLOCKED_LINK_UNDECIDED: + # 산림·건설 어느 품을 쓸지 연결고리 표가 미판정 — 근거 없이 고르지 않음(브레인 · 코덱스 ②). + result.missing.append( + { + "name": item.display_name, + "unit": item.unit, + "quantity": str(item.quantity), + "reason": f"{_BLOCKED_LABELS[BLOCKED_LINK_UNDECIDED]} — {item.blocked_reason}", + "blocked_kind": BLOCKED_LINK_UNDECIDED, + } + ) + continue if (item.composite_parts or item.composite_not_ready) and not item.work_item_code: # 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다 # (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다. @@ -577,6 +592,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]: "direct_labor_krw": str(result.direct_labor_krw), "direct_expense_krw": str(result.direct_expense_krw), "notes": result.notes, + # 연결고리 표 미판정 행 — 열쇠가 비어 줄에 못 닿는 것도 드러냄(조용히 안 버림) + "link_undecided": undecided_links(), } diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py index a48cfd2b..23112e39 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Input.py @@ -14,7 +14,11 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any -from common_util.common_util_work_item_key import work_item_key +from common_util.common_util_work_item_key import work_item_code_of, work_item_key +from common_util.common_util_work_item_link import choose + +#: 연결고리 표 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 **안 고른** 줄(금액에 안 듦). +BLOCKED_LINK_UNDECIDED = "link_undecided" _ZERO = Decimal(0) @@ -121,6 +125,23 @@ def _decimal(value: Any, default: Decimal | None = _ZERO) -> Decimal | None: return Decimal(str(value)) +def _linked(row: dict[str, Any]) -> dict[str, Any]: + """열쇠 길목 + **연결고리 표** — 산림·건설 어느 품을 쓸지 표가 정함(고시 총칙 7 · 표 `policy`). + + 둘 다 있는 공종이면 표가 적은 우선 품셈 열쇠·목차 코드로 바꿈 · 미판정이면 막음(사유 그대로). + ⚠ 이미 B08 이 막아 보낸 줄은 그 까닭을 덮지 않음. + """ + edition = row.get("pum_edition") or None + key = row.get("work_item_key") or work_item_key(row.get("work_item_code"), edition) + choice = choose(key) + out = {"work_item_key": key or "", "work_item_code": row.get("work_item_code")} + if choice.blocked and not row.get("blocked_reason"): + out.update(blocked_reason=choice.blocked, blocked_kind=BLOCKED_LINK_UNDECIDED) + elif choice.key and choice.key != key: + out.update(work_item_key=choice.key, work_item_code=work_item_code_of(choice.key, edition)) + return out + + def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[HandoffMaterial]]: """인계 응답을 우리 자료형으로 옮긴다. **모르는 칸을 채우지 않는다.**""" if "work_items" not in payload or "materials" not in payload: @@ -128,7 +149,7 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[ work_items = [ HandoffWorkItem( - work_item_code=row.get("work_item_code"), + work_item_code=linked["work_item_code"], name=row.get("name", ""), spec=row.get("spec") or "", unit=row.get("unit") or "", @@ -147,19 +168,18 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[ composite_parts=tuple(row.get("composite_parts") or ()), composite_not_ready=tuple(row.get("composite_not_ready") or ()), structure_kind=row.get("structure_kind") or "", - blocked_reason=row.get("blocked_reason") or "", - blocked_kind=row.get("blocked_kind") or "", + blocked_reason=row.get("blocked_reason") or linked.get("blocked_reason") or "", + blocked_kind=row.get("blocked_kind") or linked.get("blocked_kind") or "", variant_axis=row.get("variant_axis") or "", variant_value=str(row.get("variant_value") or ""), spec_class=row.get("spec_class") or "", spec_class_basis=row.get("spec_class_basis") or "", pum_edition=str(row.get("pum_edition") or ""), - # 옛 인계(열쇠 없음)도 같은 길목으로 채움 - work_item_key=row.get("work_item_key") - or work_item_key(row.get("work_item_code"), row.get("pum_edition") or None) - or "", + # 옛 인계(열쇠 없음)도 같은 길목으로 채움 · 연결고리 표가 고른 열쇠 + work_item_key=linked["work_item_key"], ) for row in payload["work_items"] + for linked in (_linked(row),) ] materials = [ HandoffMaterial( diff --git a/Z01_MasterData/Z01_MasterData_BasePrices.py b/Z01_MasterData/Z01_MasterData_BasePrices.py index 4a17fb8d..9db2ba37 100644 --- a/Z01_MasterData/Z01_MasterData_BasePrices.py +++ b/Z01_MasterData/Z01_MasterData_BasePrices.py @@ -427,14 +427,21 @@ def kind_label(kind: str) -> str: return named.get("name_ko") or kind +def group_label(group: str) -> str: + """상자 이름 — 이름표 `base_price_groups[갈래].name_ko` 에서만(없으면 영문 그대로 · 화면은 `group_label` 을 읽음).""" + named = (tables.load_labels().get("base_price_groups") or {}).get(group) or {} + return named.get("name_ko") or group + + def kinds() -> list[dict[str, Any]]: """화면이 상자를 세울 목록 — **서버가 냄**(화면이 제 코드에 들면 새 kind 가 조용히 안 뜸). - 갈래 · kind · 한글 이름(이름표) · 줄 수. + 갈래 · 갈래 한글 이름 · kind · 한글 이름(이름표) · 줄 수. """ return [ { "group": kind_group(kind), + "group_label": group_label(kind_group(kind)), "kind": kind, "label": kind_label(kind), "rows": len(rows(kind)), diff --git a/Z01_MasterData/Z01_MasterData_WorkItems.py b/Z01_MasterData/Z01_MasterData_WorkItems.py index 4bacb5ef..142f1505 100644 --- a/Z01_MasterData/Z01_MasterData_WorkItems.py +++ b/Z01_MasterData/Z01_MasterData_WorkItems.py @@ -1,7 +1,7 @@ """Z01 공종 축 — 산림·건설 두 kind 를 기초단가와 **같은 계약**으로(2026-09-17 브레인 · PLAN_공종축 8-4). 줄 = **품셈 표가 붙은 마디**(`axis_policy.json` `flat_list_filter: pum_tables > 0` — 잎으로 거르면 부모에 붙은 표가 안 뜸) · - 갈래(`variant_keys`)는 칸으로(편 수 = `stats.units`). + 갈래(`variant_keys`)마다 한 줄로 폄 — 줄 수 = 편 수(`stats.units`) · 공종 수 = `stats.work_items`. 이름 = 뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」 · 건설은 부문부터) — 잎 이름만으론 겹침. 계산 규칙 = 표 수준 `rules`(부모 마디 choose_one · sum_steps) + 줄 칸 `rule`·`step_weight` — 빠지면 발파암(절취 0.1 + 깍기 0.9 + 집토 1) 금액이 틀어짐. @@ -77,6 +77,9 @@ def base_rows(kind: str) -> list[dict[str, Any]]: return [] d, name = found items, by, children = _tree(d) + # 계산 규칙 이름 — 이름표 `value_overrides["work_item_master::<방식>"]`(「아래를 단계로 다 더함」) · 없으면 영문 + named = tables.load_labels().get("value_overrides") or {} + linked, roles = linked_keys(), _role_names() rows = [] for item in items: if not item.get("tables"): @@ -86,23 +89,57 @@ def base_rows(kind: str) -> list[dict[str, Any]]: (s["weight"] for s in parent.get("steps") or [] if s["code"] == item["work_item_code"]), None, ) - rows.append( - { - "@id": _key(item), - "@source": name, - "work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만 - "number": item["number"], - "name": _path(item, by), - "variant_keys": item.get("variant_keys") or [], - "pum_tables": [t["pum_table_id"] for t in item["tables"]], - "rule": parent.get("parent_mode"), - "step_weight": weight, - "effective_date": d.get("pum_edition") or d.get("effective_date"), - } - ) + key = _key(item) + # 갈래마다 한 줄 — 일위대가가 갈래마다 달라 집는 단위가 갈래임(교목굴취 나무높이 9 · 2026-09-17 브레인 ③). + # 열쇠 = 공종 열쇠#갈래 키(B08·B09 `코드#갈래` 꼴) · 갈래 키 글자는 마스터 것 그대로(조립 두 벌 금지). + for variant in item.get("variant_keys") or [None]: + rows.append( + { + "@id": f"{key}#{variant}" if variant else key, + "@key": key, # 공종 열쇠 — 거르기·줄 수는 공종 단위로 + "@source": name, + "work_item_code": item["work_item_code"], # 목차 코드 — 열쇠 아님, 칸으로만 + "number": item["number"], + "name": _path(item, by), + "variant": variant, + "pum_tables": [t["pum_table_id"] for t in item["tables"]], + "rule": (named.get(f"work_item_master::{parent.get('parent_mode')}") or {}).get( + "name_ko" + ) + or parent.get("parent_mode"), + "step_weight": weight, + # 「임도가 쓰는 것」 — 연결고리 표가 확정으로 이은 공종(판정은 그 표 · 감추지 않고 표시만) + "link": LINKED if key in linked else "", + # 줄 구실(총칙·공종) — 이름은 axis_policy.json · 표시 칸(거르기 상자로 고름) + "axis_role": roles.get(str(item.get("axis_role") or ""), ""), + "effective_date": d.get("pum_edition") or d.get("effective_date"), + } + ) return rows +LINK_FOLDER = tables.RESOURCES / "data_work_item_link" +LINKED = "연결됨" + + +def linked_keys() -> frozenset[str]: + """연결고리 표(`work_item_link_*.json` 끝 판 · 코덱스)가 **확정**으로 이은 열쇠(산림·건설). + + ⚠ 목록을 코드에 안 둠 — 판정은 그 표 · 미판정은 안 이음(표의 `unconfirmed_action: do_not_link`). + """ + found = sorted(LINK_FOLDER.glob("work_item_link_*.json")) if LINK_FOLDER.is_dir() else [] + if not found: + return frozenset() + links = _read(str(found[-1]), found[-1].stat().st_mtime_ns).get("links") or [] + return frozenset( + key + for link in links + if link.get("status") == "확정" + for key in (link.get("forest_key"), link.get("const_key")) + if key + ) + + def rules(kind: str) -> list[dict[str, Any]]: """부모 마디 계산 규칙 — choose_one(아래 중 하나) · sum_steps(아래를 무게대로 더해야 한 단위).""" found = doc(kind) @@ -144,9 +181,10 @@ def notice(kind: str) -> list[str]: return lines -#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실). 어느 줄을 거를지는 서버 한 곳 · 화면은 고른 값만 보냄. -#: ⚠ 「임도가 쓰는 것」 같은 쓰임 가름은 잣대가 아직 없어 안 냄(지어내지 않음). -FILTER_KEYS = ("division", "axis_role") +#: 거르기 — **자료에 있는 가름만**(건설 부문 · 줄 구실 · 연결고리 표). 어느 줄을 거를지는 서버 한 곳 · +#: 화면은 고른 값만 보냄 · 연결은 기본 「전부」(감추지 않음 — 「어디에 쓸지 모르니 건설 전체」 · 브레인) · +#: 줄 구실만 기본 「공종」(총칙 기본 감춤 · 서브 2ee6a7ed · 브레인). +FILTER_KEYS = ("link", "division", "axis_role") ALL = "" #: 값을 안 보냈을 때 거는 값 — 총칙 줄은 기본 감춤 · 「전부」를 고르면 보임(2026-09-17 브레인) DEFAULTS = {"axis_role": "work_item"} @@ -155,7 +193,15 @@ DEFAULTS = {"axis_role": "work_item"} def _facets(kind: str) -> dict[str, dict[str, str]]: found = doc(kind) items = found[0].get("work_items") or [] if found else [] - return {_key(i): {key: str(i.get(key) or "") for key in FILTER_KEYS} for i in items} + linked = linked_keys() + return { + _key(i): { + "link": LINKED if _key(i) in linked else ALL, + "division": str(i.get("division") or ""), + "axis_role": str(i.get("axis_role") or ""), + } + for i in items + } def _role_names() -> dict[str, str]: @@ -168,17 +214,21 @@ def _role_names() -> dict[str, str]: def filters(kind: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """거르기 상자 — 값이 둘 이상 갈리는 가름만 · 기본은 `DEFAULTS`(그 값이 표에 있을 때) 아니면 「전부」.""" + """거르기 상자 — 줄이 둘 이상으로 갈리는 가름만 · 기본은 `DEFAULTS`(그 값이 표에 있을 때) 아니면 「전부」.""" facets = _facets(kind) - labels = {"division": ("부문", {}), "axis_role": ("줄 구실", _role_names())} + labels = { + "link": ("연결", {LINKED: "연결된 것만"}), + "division": ("부문", {}), + "axis_role": ("줄 구실", _role_names()), + } out = [] for key in FILTER_KEYS: counts: dict[str, int] = {} for row in rows: - value = facets.get(row["@id"], {}).get(key, ALL) + value = facets.get(row["@key"], {}).get(key, ALL) counts[value] = counts.get(value, 0) + 1 values = [v for v in counts if v != ALL] - if len(values) < 2: + if len(counts) < 2: # 다 같은 값이면 거를 것이 없음(연결은 「연결됨」 · 빈칸 둘로 갈림) continue label, names = labels[key] default = DEFAULTS.get(key, ALL) @@ -203,7 +253,7 @@ def apply_filters( for key in FILTER_KEYS: value = picked.get(key, defaults.get(key, ALL)) if value != ALL: - rows = [r for r in rows if facets.get(r["@id"], {}).get(key) == value] + rows = [r for r in rows if facets.get(r["@key"], {}).get(key) == value] return rows @@ -214,7 +264,7 @@ def extra(kind: str) -> dict[str, Any]: "filters": filters(kind, rows), "rules": rules(kind), "stats": { - "work_items": len(rows), - "units": sum(max(1, len(r["variant_keys"])) for r in rows), + "work_items": len({r["@key"] for r in rows}), + "units": len(rows), }, } diff --git a/common_util/common_util_work_item_key.py b/common_util/common_util_work_item_key.py index 7d61f3da..94ec26dd 100644 --- a/common_util/common_util_work_item_key.py +++ b/common_util/common_util_work_item_key.py @@ -56,6 +56,20 @@ def work_item_key(code: str | None, pum_edition: str | None = None) -> str | Non return found.pop() if len(found) == 1 else None +def work_item_code_of(key: str | None, pum_edition: str | None = None) -> str | None: + """불변 열쇠 → 그 판의 목차 코드(되짚기). 한 곳에서만 찾아질 때만.""" + if not key: + return None + if _STABLE_CODE.match(key): + return key + found = { + code + for (edition, code), each in _keys().items() + if each == key and (pum_edition is None or edition == pum_edition) + } + return found.pop() if len(found) == 1 else None + + def attach_keys(rows: Iterable[dict[str, Any]]) -> list[str]: """인계 줄마다 `work_item_key` 를 더함(코드 없는 줄은 `None` — 칸은 늘 있음 · 인계 계약) · 코드는 있는데 열쇠를 못 찾은 코드 목록을 돌려줌.""" diff --git a/common_util/common_util_work_item_link.py b/common_util/common_util_work_item_link.py new file mode 100644 index 00000000..e949ecda --- /dev/null +++ b/common_util/common_util_work_item_link.py @@ -0,0 +1,87 @@ +"""연결고리 표(`work_item_link`)를 읽는 **한 자리** — 산림·건설 어느 품셈 품을 쓸지 **표가 정함** +(2026-09-17 브레인 · 코덱스 크로스체크 ② 「연결고리 미소비」). + +고시 총칙 7 「본 품셈에 명시되지 않은 품은 타 부문 표준품셈 적용 · 유사 공종은 본 품셈 우선」 — +그 규칙은 표의 `policy`(`both_precedence` · `unconfirmed_action`)에 있고 코드는 **읽기만** 함(「산림 우선」을 코드에 안 박음). + + 확정 · both → `both_precedence` 쪽 열쇠(지금 표는 forest) + 확정 · 한쪽만 → 그 열쇠 그대로 + 미판정 → **안 고름** · 사유를 붙여 막음(`unconfirmed_action: do_not_link`) + 표에 없음 → 제 품셈 그대로(품셈에 명시된 품) +⚠ 미판정 행 가운데 열쇠가 빈 행(여러 절 묶음)은 줄에 못 닿음 — `undecided_links()` 로 목록만 드러냄. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +FOLDER = Path(__file__).resolve().parent.parent / "resources" / "data_work_item_link" +CONFIRMED = "확정" +#: 표 `policy.both_precedence` 낱말 → 그 행의 열쇠 칸 +_KEY_COLUMN = {"forest": "forest_key", "construction": "const_key"} + + +@dataclass(frozen=True) +class Choice: + """쓸 열쇠 · 막힌 까닭(있으면 금액을 안 세움).""" + + key: str | None + blocked: str = "" + + +@lru_cache(maxsize=4) +def _read(path: str, _mtime_ns: int) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def _doc() -> dict[str, Any]: + found = sorted(FOLDER.glob("work_item_link_*.json")) if FOLDER.is_dir() else [] + return _read(str(found[-1]), found[-1].stat().st_mtime_ns) if found else {} + + +def choose(key: str | None) -> Choice: + """열쇠 하나(FW-·CW-) → 실제로 쓸 열쇠. 미판정 행에 걸리면 안 고르고 막음.""" + if not key: + return Choice(key) + doc = _doc() + policy = doc.get("policy") or {} + rows = [ + link + for link in doc.get("links") or [] + if key in (link.get("forest_key"), link.get("const_key")) + ] + for link in rows: + if link.get("status") != CONFIRMED: + return Choice( + None, + "연결고리 미판정 — 산림·건설 어느 품을 쓸지 근거가 없어 고르지 않음" + f"({link.get('evidence') or '근거 없음'})", + ) + for link in rows: + if link.get("branch") == "both": + column = _KEY_COLUMN.get(str(policy.get("both_precedence") or "")) + chosen = link.get(column) if column else None + if not chosen: + return Choice( + None, "연결고리 표에 둘 다 있는 줄의 우선 품셈이 안 적혔음 — 고르지 않음" + ) + return Choice(chosen) + return Choice(key) + + +def undecided_links() -> list[dict[str, str]]: + """미판정 행 — 열쇠가 비어 줄에 못 닿는 행도 드러냄(조용히 안 버림).""" + return [ + { + "branch": str(link.get("branch") or ""), + "forest_key": str(link.get("forest_key") or ""), + "const_key": str(link.get("const_key") or ""), + "evidence": str(link.get("evidence") or ""), + } + for link in _doc().get("links") or [] + if link.get("status") != CONFIRMED + ] diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 1a560ad0..2ef02421 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -1389,10 +1389,11 @@ "note": "뿌리부터 전체 경로(「토공 › 토사깍기 › 기계」) — 잎 이름만으론 겹침." }, { - "key": "variant_keys", - "name_ko": "갈래 키", + "key": "variant", + "name_ko": "갈래", "unit": "", - "visible": true + "visible": true, + "note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래." }, { "key": "pum_tables", @@ -1414,6 +1415,20 @@ "visible": true, "note": "`sum_steps` 일 때만 — 발파암 절취 0.1 · 깍기 0.9 · 집토 1." }, + { + "key": "link", + "name_ko": "연결", + "unit": "", + "visible": true, + "note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만." + }, + { + "key": "axis_role", + "name_ko": "줄 구실", + "unit": "", + "visible": true, + "note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)." + }, { "key": "effective_date", "name_ko": "적용 시작일", @@ -1452,10 +1467,11 @@ "note": "부문부터 전체 경로(「공통부문 › 토공사 › 굴착 › 굴착(인력/토사)」) — 부문마다 장 번호가 1부터라 부문이 뿌리." }, { - "key": "variant_keys", - "name_ko": "갈래 키", + "key": "variant", + "name_ko": "갈래", "unit": "", - "visible": true + "visible": true, + "note": "갈래마다 한 줄(일위대가가 갈래마다 다름) — 줄 열쇠는 공종 열쇠#갈래." }, { "key": "pum_tables", @@ -1477,6 +1493,20 @@ "visible": true, "note": "`sum_steps` 일 때만 — 건설은 아직 합산형 선언 없음." }, + { + "key": "link", + "name_ko": "연결", + "unit": "", + "visible": true, + "note": "「연결됨」 = 연결고리 표(`work_item_link`)가 확정으로 이은 줄 — 임도가 쓰는 공종. 판정은 그 표 · 감추지 않고 표시만." + }, + { + "key": "axis_role", + "name_ko": "줄 구실", + "unit": "", + "visible": true, + "note": "총칙·공종 — 잣대는 `axis_policy.json`(데스크탑 서브). 총칙도 지우지 않고 표시만(원가가 값표를 읽음)." + }, { "key": "effective_date", "name_ko": "적용 시작일", @@ -1930,7 +1960,7 @@ }, "counts": { "merged_tables": 17, - "merged_columns": 172, + "merged_columns": 176, "files": 41, "tables": 100, "columns": 532, diff --git a/resources/tester/test_work_item_key_gate.py b/resources/tester/test_work_item_key_gate.py index 7df9eeb0..700fad49 100644 --- a/resources/tester/test_work_item_key_gate.py +++ b/resources/tester/test_work_item_key_gate.py @@ -137,3 +137,27 @@ def test_금액_불변_열쇠가_있든_없든_내역서가_한_원도_안_다 ] priced = [row for row in new.rows if not row.is_group and row.amount_krw] assert len(priced) >= 3 and new.body_total_krw > 0 # 금액이 실제로 선 줄로 잼 + + +#: ⭐ 이행 **전** 코드(4bfbf98f^ · 길목 없음)로 위 `_handoff()` 를 세워 뜬 금액 — 2026-09-17 랩탑 메인. +#: 이행 뒤 코드도 내역서 줄 18·성분·원가계산서 지문까지 한 글자 같았음(옛 두 파일을 따로 얹어 맞댐). +#: ⚠ 단가 자료(노임·기계·자재·요율)가 바뀌면 이 수도 바뀜 — 그땐 길목 탓이 아닌지 먼저 볼 것 +#: (열쇠 있든 없든 같은지 = 위 시험) · 길목 탓이면 고칠 것은 코드, 아니면 다시 뜸. +PRE_MIGRATION_KRW = { + "material_cost": "1158206", + "labor_cost": "4400597", + "expense": "1991430", + "direct_construction_cost": "5983724", + "net_construction_cost": "7550233", + "total_cost": "9203637", + "grand_total": "10124000", +} + + +def test_금액_불변_이행_전_코드로_뜬_원가계산서와_한_원도_같음() -> None: + from B09_Estimation.B09_Estimation_BillOfQuantities import cost_input_from_bill + from B09_Estimation.B09_Estimation_Engine_Cost import calculate_cost + + bill = build_bill(copy.deepcopy(_handoff()), build=build_unit_prices()) + totals = calculate_cost(cost_input_from_bill(bill)).totals + assert {key: str(totals[key]) for key in PRE_MIGRATION_KRW} == PRE_MIGRATION_KRW diff --git a/resources/tester/test_work_item_link_choice.py b/resources/tester/test_work_item_link_choice.py new file mode 100644 index 00000000..99d560b6 --- /dev/null +++ b/resources/tester/test_work_item_link_choice.py @@ -0,0 +1,138 @@ +"""연결고리 표 소비 — 산림·건설 어느 품을 쓸지 **표가 정함** (2026-09-17 브레인 · 코덱스 크로스체크 ②). + +고시 총칙 7 「유사 공종은 본 품셈 우선」 이 규칙으로만 있고 아무도 안 읽던 자리. 못박는 것: + ① 둘 다 있는 공종은 표의 `both_precedence` 쪽 — 표를 바꾸면 고르는 쪽도 바뀜(코드에 안 박힘) + ② 미판정은 안 고르고 막음 · 사유 그대로 · 금액에 안 듦 + ③ 지금 인계 코드는 한 줄도 판정이 안 달라짐 = **금액 불변**(이행 전 금액은 `test_work_item_key_gate` 가 못박음) +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill +from B09_Estimation.B09_Estimation_BillOfQuantities_Input import ( + BLOCKED_LINK_UNDECIDED, + parse_handoff, +) +from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices +from common_util import common_util_work_item_link as link +from common_util.common_util_work_item_key import MASTER_DIR, work_item_code_of, work_item_key + +MAPPING = MASTER_DIR.parent / "data_work_item_mapping" / "work_item_mapping_2026-01-01.json" + + +def _table( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, links: list, precedence="forest" +) -> None: + folder = tmp_path / "data_work_item_link" + folder.mkdir(parents=True) + doc = { + "policy": {"both_precedence": precedence, "unconfirmed_action": "do_not_link"}, + "links": links, + } + (folder / "work_item_link_2026-01-01.json").write_text( + json.dumps(doc, ensure_ascii=False), encoding="utf-8" + ) + monkeypatch.setattr(link, "FOLDER", folder) + + +def test_지금_인계_코드는_판정이_안_달라짐_금액_불변() -> None: + codes = set( + re.findall(r'"((?:FP|AX-WK|AX-ST)-[0-9A-Za-z-]+)"', MAPPING.read_text(encoding="utf-8")) + ) + changed = [] + for code in sorted(codes): + key = work_item_key(code, "2026-01-01") + choice = link.choose(key) + if choice.key != key or choice.blocked: + changed.append((code, key, choice)) + assert changed == [] # 달라지면 그동안 잘못 고르고 있었다는 뜻 — 브레인에 먼저 알릴 것 + + +def test_둘_다_있으면_표가_적은_쪽() -> None: + """실제 표 — 산림 9-7-1 무근콘크리트 깨기 ↔ 건설 8-2-13 대형브레이커(E 0.35 ↔ 0.45).""" + assert link.choose("CW-00330").key == "FW-00260" + assert link.choose("FW-00260").key == "FW-00260" + assert link.choose("CW-00844").key == "CW-00844" # 건설에만(모르타르 배합) — 그대로 + assert link.choose("FW-00249").key == "FW-00249" # 표에 없음 — 제 품셈 + + +def test_우선_품셈은_코드가_아니라_표가_정함( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + rows = [{"forest_key": "FW-00260", "const_key": "CW-00330", "branch": "both", "status": "확정"}] + _table(tmp_path, monkeypatch, rows, precedence="construction") + assert link.choose("FW-00260").key == "CW-00330" + _table(tmp_path / "b", monkeypatch, rows, precedence="") + assert link.choose("FW-00260").key is None and link.choose("FW-00260").blocked + + +def test_미판정은_안_고르고_막음_금액에_안_듦( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = { + "work_items": [ + {"work_item_code": "FP-09-03-02", "name": "토사깍기", "unit": "㎥", "quantity": 100}, + {"work_item_code": "FP-09-12-01", "name": "측구터파기", "unit": "㎥", "quantity": 7}, + ], + "materials": [], + } + build = build_unit_prices() + before = build_bill(json.loads(json.dumps(payload)), build=build) + ditch = work_item_key("FP-09-12-01", "2026-01-01") + rows = [ + { + "forest_key": ditch, + "const_key": "", + "branch": "both", + "status": "미판정", + "evidence": "시험 — 측구 터파기 건설 대응 미확인", + } + ] + _table(tmp_path, monkeypatch, rows) + items, _ = parse_handoff(json.loads(json.dumps(payload))) + assert ( + items[1].blocked_kind == BLOCKED_LINK_UNDECIDED and "시험 — 측구" in items[1].blocked_reason + ) + after = build_bill(json.loads(json.dumps(payload)), build=build) + blocked = [m for m in after.missing if m.get("blocked_kind") == BLOCKED_LINK_UNDECIDED] + assert [m["name"] for m in blocked] == ["측구터파기"] + ditch_amount = sum( + r.amount_krw or 0 for r in before.rows if not r.is_group and r.name == "측구터파기" + ) + assert ditch_amount > 0 and after.body_total_krw == before.body_total_krw - ditch_amount + assert bill_summary(after)["link_undecided"][0]["evidence"].startswith("시험") + + +def test_건설_열쇠로_온_줄은_표가_고른_산림_열쇠와_목차_코드로() -> None: + const_code = work_item_code_of("CW-00330") + assert const_code and const_code.startswith("CP-") + items, _ = parse_handoff( + { + "work_items": [ + { + "work_item_code": const_code, + "name": "대형브레이커", + "unit": "㎥", + "quantity": 1, + "pum_edition": "2026-01-01", + } + ], + "materials": [], + } + ) + assert (items[0].work_item_key, items[0].work_item_code) == ( + "FW-00260", + work_item_code_of("FW-00260"), + ) + assert items[0].work_item_code.startswith("FP-") + + +def test_미판정_행은_열쇠가_비어도_목록으로_드러남() -> None: + undecided = link.undecided_links() + assert undecided and all(row["evidence"] for row in undecided) diff --git a/resources/tester/test_z01_work_items.py b/resources/tester/test_z01_work_items.py index d32ec95a..5713d692 100644 --- a/resources/tester/test_z01_work_items.py +++ b/resources/tester/test_z01_work_items.py @@ -38,15 +38,21 @@ def _get(client: TestClient, kind: str, **params) -> dict: def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> None: listed = client.get("/api/master-data/base-prices").json()["kinds"] forest = next(i for i in listed if i["kind"] == "work_item_forest") - assert forest["group"] == "work_item" and forest["rows"] == 365 + assert forest["group"] == "work_item" and forest["rows"] == 627 # 갈래마다 한 줄 + # 상자 이름은 이름표 것 — 영문 키(work_item)로 뜨던 자리(2026-09-17 브레인 ①) + assert {i["group"]: i["group_label"] for i in listed} == { + "base_price": "기초단가", + "pumsem_basis": "품셈 기준", + "work_item": "공종 축", + } table = _get(client, "work_item_forest", size=500, axis_role="") # 「전부」 — 총칙까지 - assert table["total"] == 365 and table["stats"] == {"work_items": 365, "units": 627} + assert table["total"] == 627 and table["stats"] == {"work_items": 365, "units": 627} assert table["editable"] == [] and set(table["locked"]) == set(table["sortable"]) - rows = table["rows"] - assert len({r["@id"] for r in rows}) == 365 + rows = table["rows"] + _get(client, "work_item_forest", size=500, page=2, axis_role="")["rows"] + assert len({r["@id"] for r in rows}) == 627 assert ( - len({r["name"] for r in rows}) == 365 + len({r["@key"] for r in rows}) == len({r["name"] for r in rows}) == 365 ) # 잎 이름 겹침 — 경로로 풀림 · 목차 오기 바로잡아 358 → 360(12-17-2 · 12-24-1) by_code = {r["work_item_code"]: r for r in rows} assert by_code["FP-09-03-02"]["name"] == "토공 › 토사깍기 › 기계" @@ -55,14 +61,16 @@ def test_산림_평평한_잎_목록과_계산_규칙(client: TestClient) -> Non assert modes.count("choose_one") == 91 and modes.count("sum_steps") == 3 blast = next(r for r in table["rules"] if r["work_item_code"] == "FP-09-05") assert [s["weight"] for s in blast["steps"]] == ["0.1", "0.9", "1"] - assert by_code["FP-09-05-02"]["rule"] == "sum_steps" + # 계산 규칙 칸은 이름표 이름 — 「sum_steps」 영문으로는 더해야 하는 줄로 안 읽힘(브레인 ②) + assert by_code["FP-09-05-02"]["rule"] == "아래를 단계로 다 더함" + assert by_code["FP-09-03-02"]["rule"] == "아래 하나를 고름" assert by_code["FP-09-05-02"]["step_weight"] == "0.9" # 2026-09-17 FW- 가 옴(데스크탑 서브) — 알림이 사라지는 것이 곧 열쇠가 섰다는 증거. assert all(r["@id"].startswith("FW-") for r in rows) # 불변 열쇠(데스크탑 서브 7dd2c515) assert not any("불변 열쇠" in line for line in table["notice"]) found = _get(client, "work_item_forest", q="발파암 ›") - assert found["total"] == 3 + assert found["total"] == 5 # 절취 · 깍기(연암·보통암·경암) · 집토 def test_건설_공종_축도_같은_길(client: TestClient) -> None: @@ -101,7 +109,8 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( monkeypatch.setattr(work_items, "FOLDER", folder) table = _get(client, "work_item_const") - assert [r["@id"] for r in table["rows"]] == ["CW-00002"] + assert [r["@id"] for r in table["rows"]] == ["CW-00002#갑", "CW-00002#을"] # 갈래마다 한 줄 + assert {r["@key"] for r in table["rows"]} == {"CW-00002"} assert table["rows"][0]["work_item_code"] == "CP-01-01" # 목차 코드는 칸으로 남음 assert table["rows"][0]["name"] == "토공 › 인력" assert table["stats"] == {"work_items": 1, "units": 2} @@ -109,7 +118,7 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( assert _get(client, "work_item_forest")["total"] == 0 # 산림 파일은 이 폴더에 없음 res = client.put( - "/api/master-data/base-prices/work_item_const/CW-00002", + "/api/master-data/base-prices/work_item_const/CW-00002%23갑", json={"values": {"name": "바꿈"}}, ) assert res.status_code == 400 and "공종 축 뼈대" in res.json()["detail"] @@ -117,10 +126,10 @@ def test_불변_열쇠가_오면_id_로_씀_자료_파일은_판_id_로_가림( def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestClient) -> None: """화면(서브 b890fba0)이 `filters` 를 받으면 상자를 세우고 고른 값만 보냄 — 판정은 서버 한 곳. - 건설 부문 다섯 · 줄 구실(이름은 axis_policy.json). ⚠ 「임도가 쓰는 것」은 잣대가 없어 안 냄.""" + 건설 부문 다섯 · 줄 구실(이름은 axis_policy.json) · 연결(연결고리 표).""" const = _get(client, "work_item_const", size=1, axis_role="") by_key = {f["key"]: f for f in const["filters"]} - assert set(by_key) == {"division", "axis_role"} + assert set(by_key) == {"link", "division", "axis_role"} division = by_key["division"] assert division["default"] == "" assert division["options"][0] == {"value": "", "label": "전부", "rows": const["total"]} @@ -146,11 +155,67 @@ def test_거르기는_자료에_있는_가름만_서버가_판정(client: TestCl ) assert rules["total"] > 0 assert all(r["name"].startswith("공통부문 › 적용기준") for r in rules["rows"]) + assert {r["axis_role"] for r in rules["rows"]} == {"총칙"} # 표시 칸 — 감추지 않고 보임 - assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == ["axis_role"] + assert [f["key"] for f in _get(client, "work_item_forest", size=1)["filters"]] == [ + "link", + "axis_role", + ] assert _get(client, "labor", size=1, division="토목부문")["total"] == 261 # 다른 표는 안 거름 +def test_연결고리_표가_이은_줄에_연결됨_감추지_않고_표시만(client: TestClient) -> None: + """브레인 2026-09-17 — 「임도가 쓰는 것」 = 연결고리 표(work_item_link)가 **확정**으로 이은 줄. + 기본은 다 보임 · 표시 「연결됨」 · 「연결된 것만」 상자 하나 · 판정은 그 표(코드에 목록 없음).""" + from Z01_MasterData import Z01_MasterData_WorkItems as work_items + + link_doc = json.loads( + sorted(work_items.LINK_FOLDER.glob("work_item_link_*.json"))[-1].read_text(encoding="utf-8") + ) + confirmed = { + key + for link in link_doc["links"] + if link["status"] == "확정" + for key in (link["forest_key"], link["const_key"]) + if key + } + unconfirmed = { + key + for link in link_doc["links"] + if link["status"] != "확정" + for key in (link["forest_key"], link["const_key"]) + if key + } - confirmed + for kind, prefix in (("work_item_const", "CW-"), ("work_item_forest", "FW-")): + every = _get(client, kind, size=500, axis_role="") + rows = every["rows"] + _get(client, kind, size=500, page=2, axis_role="")["rows"] + assert len(rows) == every["total"] # 연결 거르기 기본은 「전부」 — 다 보임 + marked = {r["@key"] for r in rows if r["link"] == "연결됨"} # 갈래 줄은 공종 열쇠로 + listed = {r["@key"] for r in rows} # 표가 안 붙은 묶는 마디(CW-01329 따위)는 목록 밖 + assert marked == {k for k in confirmed if k.startswith(prefix) and k in listed} + assert marked # 연결이 늘면 저절로 늘어남 — 지금도 한 줄 이상 + assert not marked & unconfirmed + only = _get(client, kind, size=500, link="연결됨", axis_role="") + assert {r["@key"] for r in only["rows"]} == marked + box = next(f for f in every["filters"] if f["key"] == "link") + assert box["default"] == "" and box["options"][1]["rows"] == only["total"] + + +def test_갈래마다_한_줄이라_편_하나를_집는다(client: TestClient) -> None: + """2026-09-17 브레인 ③ — 일위대가는 갈래마다 다름. 교목굴취(나무높이) 한 줄이면 어느 높이 값인지 못 고름.""" + rows = _get(client, "work_item_forest", size=50, q="교목굴취(나무높이)")["rows"] + assert [r["variant"] for r in rows] == [ + "1.0 이하", "1.1∼1.5", "1.6∼2.0", "2.1∼2.5", "2.6∼3.0", + "3.1∼3.5", "3.6∼4.0", "4.1∼4.5", "4.6∼5.0", + ] # fmt: skip + assert rows[1]["@id"] == "FW-00105#1.1∼1.5" and rows[1]["@key"] == "FW-00105" + # 갈래 없는 공종은 한 줄 · 열쇠에 # 없음 + plain = _get(client, "work_item_forest", q="교목굴취(근원직경)")["rows"] + assert [(r["@id"], r["variant"]) for r in plain] == [("FW-00106", None)] + # 갈래 이름으로도 찾힘 + assert _get(client, "work_item_forest", q="2.1∼2.5")["total"] >= 1 + + def test_총칙_줄은_기본_감춤_전부를_고르면_보임(client: TestClient) -> None: """2026-09-17 브레인 — 값을 안 보내면 상자 기본값(공종)으로 거름 · 화면은 그 기본값을 상자에 보임.""" for kind in ("work_item_forest", "work_item_const"):