diff --git a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py index 427174ae..a7655e03 100644 --- a/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py +++ b/B08_Quantity/B08_Quantity_Engine_StmateRecipe.py @@ -176,6 +176,21 @@ def recipe_item( "reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY, } ) + # ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**: + # 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름. + # 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정). + unit_rows = [ + { + "seq": index, + "name": row["name"], + "spec": row["spec"], + "unit": row["unit"], + "from_row": row["seq"], + } + for index, row in enumerate( + (row for row in rows if row["destination"] == "unit_price"), start=1 + ) + ] notes = [NOTE_SOURCE_QTY] if percent: notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent))) @@ -191,6 +206,11 @@ def recipe_item( "vars": {}, "tables": {}, "rows": rows, + "unit_price": { + "unit": hopyo["unit"], + "note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)", + "rows": unit_rows, + }, # 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장). "origin": { "kind": "stmate_xlsx", diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts index 66ff8295..f61944a7 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_UnitPriceEdit.ts @@ -136,6 +136,11 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement { find.addEventListener("click", () => { picking = index; render(); + // 코드가 아직 없는 줄은 그 줄 이름으로 **후보만** 띄움 — 고르는 것은 사용자(명세 2장 · 이름 자동 확정 금지). + if (!row.ref_code && !row.work_item_code && row.name) { + query.value = row.name; + void search(); + } picker.querySelector("input")?.focus(); }); code.append(" ", find); diff --git a/resources/tester/test_b08_stmate_fixed_chain.py b/resources/tester/test_b08_stmate_fixed_chain.py new file mode 100644 index 00000000..d7d96d63 --- /dev/null +++ b/resources/tester/test_b08_stmate_fixed_chain.py @@ -0,0 +1,121 @@ +"""고정형 항목 사슬 ㉮ — 인계·내역으로 가는 문(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 + + +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원 diff --git a/resources/tester/test_b08_stmate_recipe_read.py b/resources/tester/test_b08_stmate_recipe_read.py index bb80f8bf..75d4d479 100644 --- a/resources/tester/test_b08_stmate_recipe_read.py +++ b/resources/tester/test_b08_stmate_recipe_read.py @@ -122,6 +122,10 @@ def test_착공판_계약단가_줄은_안_뽑고_항목에_적는다(tmp_path: assert "가산 행 1줄" in item["note"] and "공구손료" in item["note"] assert "계약단가·낙찰율 줄 1줄" in item["note"] and "현행 품셈 대조 전" in item["note"] assert item["item_kind"] == "fixed" and item["origin"]["project"] == "시험" + # ㉮ 일위대가 구성 — 일위대가로 가는 줄만(별산·가산 행 뺌) · 공종 코드는 비워 둠(사용자가 고름) + unit_rows = item["unit_price"]["rows"] + assert [(r["name"], r["from_row"]) for r in unit_rows] == [("메쌓기", 2)] + assert not any(r.get("work_item_code") or r.get("ref_code") for r in unit_rows) def test_시트가_없으면_사유(tmp_path: Path) -> None: