"""고정형 항목 사슬 ㉮ — 인계·내역으로 가는 문(2026-09-14 브레인 판정 「공종 코드가 없으면 어디에도 안 감」). 실측(936be972): 뽑은 고정형엔 일위대가 구성(`unit_price.rows`)이 없어 `priced_sheets` 가 그 장을 안 셈 → 구조물 줄 수량 0 · 내역 줄 사라짐 · 본체 −4,062,480. 고친 길: 일위대가로 가는 줄마다 구성 줄을 두되 **공종 코드는 비움** → 그 장은 호표 줄로 인계되고 금액은 「미완 — 공종 코드 미정」(조용한 0 아님) → 사용자가 고르개로 코드를 고르면 금액이 섬. 자원 축은 후보만(이름 자동 확정 금지 · 명세 2장). """ from __future__ import annotations import sys from decimal import Decimal from pathlib import Path from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item # noqa: E402 from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import ( # noqa: E402 price_sheets, priced_sheets, ) from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import with_rows # noqa: E402 from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError # noqa: E402 HOPYO = { "no": 6, "source_code": "B00010", "name": "기슭막이", "spec": "H=2.0m", "unit": "M", "basis": [], "contract_rows": 0, "rows": [ { "name": "깬잡석채집", "spec": "L=45cm", "amount": 2.09, "unit": "M2", "remark": "", "source_code": "", }, { "name": "고임돌채집", "spec": "기계", "amount": 0.31, "unit": "M3", "remark": "", "source_code": "", }, ], } class Book: titles = {"B-FP-X#채집": ("깬잡석 채집", "㎡", Money3(Decimal(0), Decimal(1000), Decimal(0)))} def title(self, code: str) -> SimpleNamespace: if code not in self.titles: raise PriceBookError(f"단가표에 없는 코드입니다: {code}") name, unit, _money = self.titles[code] return SimpleNamespace(name=name, spec="", unit=unit) def resolve(self, code: str) -> Money3: return self.titles[code][2] if code in self.titles else self.title(code) def _sheet(item: dict) -> dict: """`apply_templates` 가 낸 장 모양 — 고정형 줄 = 박힌 단위량.""" return { "type_id": item["type_id"], "title": "돌쌓기(찰) H=2.5", "formula_sheet": {"vars": {}}, "billing_unit": "m", "members": [{"structure_id": "s1", "billing_quantity": 10}], "rows": [ { "no": row["seq"], "unit_amount": float(row["amount"]), "unit": row["unit"], "destination": row["destination"], } for row in item["rows"] ], } def test_고정형_장이_호표_줄로_인계되고_코드_없으면_미완() -> None: item = { **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), "code": "AX-ST-0000abcd", } entries = priced_sheets([_sheet(item)], {"masonry_wet": item}) assert [e["structure_ids"] for e in entries] == [["s1"]] # 전엔 빈 목록 — 인계에서 끊겼음 prices = price_sheets(entries, Book(), lambda code, value: None, [item]) (entry,) = prices.values() assert entry["blocked"] == 2 and entry["total"] == 0 assert all("공종 코드 미정" in reason for reason in entry["reasons"]) def test_찾기는_코드_없는_줄_이름으로_후보만_띄운다() -> None: ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts").read_text( encoding="utf-8" ) assert "query.value = row.name" in ui and "!row.ref_code && !row.work_item_code" in ui # 후보 0 이면 다음 길(갈래 바꾸기 · 수동 단가)을 적음 — 조용히 「없음」으로 막히지 않게(2026-09-14 브레인) assert "수동 단가를 넣을 것" in ui def test_사용자가_코드를_고르면_금액이_선다() -> None: item = { **recipe_item(HOPYO, type_id="masonry_wet", file_name="a.xlsx", project="p"), "code": "AX-ST-0000abcd", } picked = [ dict(row, ref_code="B-FP-X#채집", unit="㎡") for row in item["unit_price"]["rows"][:1] ] template = with_rows(item, picked) entries = priced_sheets([_sheet(item)], {"masonry_wet": template}) (entry,) = price_sheets(entries, Book(), lambda code, value: None, [template]).values() assert entry["blocked"] == 0 and entry["total"] == Decimal("2090") # 2.09㎡ × 1,000원