From e6e6f9b7d5b503ecbb197414e9d8c8145295fd20 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 10:41:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08,b09):=20=EC=82=AC=EA=B8=89=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EC=B4=9D=EA=B4=84=20=EC=A4=84=EC=9D=B4=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=8B=A8=EA=B0=80=EB=A1=9C=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EB=B3=B8=EC=B2=B4=20=E3=80=8C=EC=9E=90=EC=9E=AC(=EC=82=AC?= =?UTF-8?q?=EA=B8=89)=E3=80=8D=20=EC=A4=84=EC=9D=B4=20=EB=90=A8=20=C2=B7?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EC=82=AC=EA=B8=89=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=EA=B3=84=EC=83=81=20=EA=B0=80=EB=93=9C(=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9A=B0=EC=84=A0)=20=C2=B7=20=EA=B7=9C=EC=A4=80?= =?UTF-8?q?=ED=8B=80=20=EC=9E=AC=EB=A3=8C=20=EC=9D=B4=EB=A6=84/=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=EA=B0=80=EB=A6=84(=ED=95=A0=EC=A6=9D=20=EA=B0=81?= =?UTF-8?q?=EC=9E=AC=205=C2=B7=ED=8C=90=EC=9E=AC=2010)=20=C2=B7=20?= =?UTF-8?q?=ED=95=A0=EC=A6=9D=20=EB=B3=B4=EB=A5=98=C2=B7=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EC=97=86=EC=9D=8C=20=EC=82=AC=EC=9C=A0=20=C2=B7=20=ED=95=A0?= =?UTF-8?q?=EC=A6=9D=20=E3=80=8C=EC=9D=B4=EB=82=B4=E3=80=8D=20=EC=B9=B8=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn --- .../B08_Quantity_Engine_MaterialSummary.py | 59 ++++++++- .../B08_Quantity_Engine_Preparation.py | 7 +- B08_Quantity/B08_Quantity_Router_Earthwork.py | 3 + B08_Quantity/B08_Quantity_Router_Material.py | 3 + .../B09_Estimation_BillOfQuantities.py | 12 +- ...9_Estimation_BillOfQuantities_Materials.py | 120 ++++++++++++++++++ .../B09_Estimation_BillOfQuantities_Rows.py | 17 ++- .../B09_Estimation_MaterialPrices.py | 45 +++++-- .../B09_Estimation_MaterialSheet.py | 40 +++++- B09_Estimation/B09_Estimation_Router.py | 11 +- .../B09_Estimation_Router_MaterialPrices.py | 11 +- .../B09_Estimation_UI_Tab_MaterialPrices.ts | 48 +++++-- .../data_material_surcharge/_manifest.json | 4 +- .../material_surcharge_2026-01-01.json | 8 +- resources/tester/test_b08_frame_material.py | 17 ++- resources/tester/test_b08_handoff.py | 7 +- resources/tester/test_b08_material_summary.py | 43 +++++-- resources/tester/test_b09_material_prices.py | 74 ++++++++++- 18 files changed, 468 insertions(+), 61 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py index b1471b29..5e025581 100644 --- a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을 NOTE_RATE_MISSING = "할증률 미확보" NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함" +#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트 +#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도 +#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정. +NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름" def _latest_dataset_path(directory: Path | None = None) -> Path | None: @@ -109,6 +113,9 @@ class SurchargeTable: effective_date: str = "" source: dict[str, Any] = field(default_factory=dict) rates: dict[str, dict[str, Any]] = field(default_factory=dict) + #: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄. + #: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림. + missing_reasons: dict[str, str] = field(default_factory=dict) def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]: """(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**""" @@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable: for row in payload.get("rates_pct", []) if row.get("material") is not None and row.get("rate") is not None } + reasons = { + str(item.get("material")): ( + f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}」" + f" {item.get('rate')}% · {item.get('why_not_applied')}" + ) + for item in (payload.get("candidates_pending_user") or {}).get("items") or [] + if item.get("material") + } + reasons.update( + { + str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함" + for name in (payload.get("not_found") or {}).get("materials") or [] + } + ) return SurchargeTable( effective_date=str(payload.get("effective_date") or ""), source=payload.get("source") or {}, rates=rates, + missing_reasons=reasons, ) @@ -154,7 +176,10 @@ class MaterialRow: spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ) net_amount: float = 0.0 # 순수량 — 할증 전 surcharge_pct: float | None = None # None = 미확보 + surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장 supply: str = SUPPLY_UNKNOWN + #: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음. + supply_default: bool = False install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다 surcharge_included: bool = False # 품셈에 이미 포함 basis: str = "" @@ -187,6 +212,8 @@ class MaterialRow: parts.append(self.basis) if self.supply == SUPPLY_OWNER and self.install_by is None: parts.append(NOTE_INSTALL_BY_MISSING) + if self.supply_default: + parts.append(NOTE_SUPPLY_DEFAULT) return " · ".join(parts) @@ -286,6 +313,19 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any: return None +def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]: + """(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지).""" + if raw is None or str(raw).strip() == "" or cap is None: + return cap, "" + try: + value = float(raw) + except (TypeError, ValueError): + return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀" + if not 0 <= value <= cap: + return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)" + return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)" + + #: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈). UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)" @@ -320,11 +360,16 @@ def build_table( supply_map: dict[str, Any] | None = None, extra_materials: Iterable[dict[str, Any]] = (), concrete_placing_method: str | None = None, + surcharge_overrides: dict[str, Any] | None = None, ) -> dict[str, Any]: """화면·API 가 그대로 쓰는 모양. `extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다. 모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`). + + `surcharge_overrides` — `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1 + 「표의 값 **이내**」라 **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔 안 걺 + (넘으면 안 쓰고 사유). """ table = surcharge_table or load_surcharge_table() rows, skipped = _collect(unit_quantity_table) @@ -341,9 +386,14 @@ def build_table( missing_rate: list[str] = [] missing_supply: list[str] = [] missing_install_by: list[str] = [] + chosen_rates = surcharge_overrides or {} for row in rows.values(): name = row.name - row.supply, row.install_by = _supply_of(_supply_setting(supply, row)) + setting = _supply_setting(supply, row) + row.supply, row.install_by = ( + _supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None) + ) + row.supply_default = setting is None if row.supply == SUPPLY_UNKNOWN: missing_supply.append(row.supply_key) # ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다. @@ -353,8 +403,11 @@ def build_table( continue lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method) rate, basis = table.rate_for(lookup) + row.surcharge_cap = rate + rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key)) row.surcharge_pct = rate - row.basis = " · ".join(part for part in (basis, alias_note) if part) + reason = table.missing_reasons.get(lookup, "") if rate is None else "" + row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part) if rate is None: missing_rate.append(name) @@ -379,6 +432,8 @@ def build_table( "unit": row.unit, "net_amount": row.net_amount, "surcharge_pct": row.surcharge_pct, + # 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄). + "surcharge_cap": None if row.surcharge_included else row.surcharge_cap, "total_amount": row.total_amount, "supply": row.supply, "supply_label": SUPPLY_LABELS.get(row.supply, row.supply), diff --git a/B08_Quantity/B08_Quantity_Engine_Preparation.py b/B08_Quantity/B08_Quantity_Engine_Preparation.py index c2ab2276..198369a8 100644 --- a/B08_Quantity/B08_Quantity_Engine_Preparation.py +++ b/B08_Quantity/B08_Quantity_Engine_Preparation.py @@ -523,6 +523,9 @@ FRAME_MATERIAL_SOURCE = ( " 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음" ) FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80} +#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음 +#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로. +FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")} def frame_material_rows( @@ -549,9 +552,11 @@ def frame_material_rows( except (TypeError, ValueError): per_ea = float(default) picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)" + material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, "")) rows.append( { - "name": name, + "name": material, + "spec": spec, "unit": unit, "amount": float(count) * per_ea, "destination": "material", diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 5cbd698b..64202a35 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -432,6 +432,8 @@ class QuantitySettingsBody(BaseModel): # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). material_supply: dict[str, Any] | None = None + # 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름). + material_surcharge: dict[str, Any] | None = None # 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다. concrete_placing_method: str | None = None # 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②). @@ -574,6 +576,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) - ( "rock_methods", "material_supply", + "material_surcharge", "concrete_placing_method", "topsoil_target", "tree_waste", diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 2b767788..93999bf5 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -186,6 +186,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, # 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥). concrete_placing_method=settings.get("concrete_placing_method"), ) @@ -439,6 +440,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, concrete_placing_method=settings.get("concrete_placing_method"), ) @@ -455,6 +457,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse: material_table = build_material_table( unit_table, supply_map=settings.get("material_supply") or {}, + surcharge_overrides=settings.get("material_surcharge") or {}, concrete_placing_method=settings.get("concrete_placing_method"), extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}), ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 86b1c528..85079cbb 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -428,12 +428,17 @@ def build_bill( master: dict[str, Any] | None = None, structure_prices: dict[str, dict[str, Any]] | None = None, rates: dict[str, Any] | None = None, + material_prices: dict[str, Any] | None = None, ) -> BillResult: """인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다. `structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`, **이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬. + `material_prices` — 「자재 단가」 탭 저장본. 사급 자재총괄 줄이 이 값으로 본체 「자재」 줄이 됨. """ + from B09_Estimation.B09_Estimation_MaterialPrices import normalize as normalize_prices + + manual_prices = normalize_prices(material_prices) work_items, materials = parse_handoff(payload) unit_prices = build or cached_build() master = master or load_work_item_master() @@ -576,7 +581,7 @@ def build_bill( # ── 3) 자재 벌 ──────────────────────────────────────────────────────────── for material in materials: - result.material_rows.append(_material_row(material, result)) + result.material_rows.append(_material_row(material, result, manual_prices)) # ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ────────────────────── check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded]) @@ -649,7 +654,12 @@ def build_bill( result.material_sheet = build_material_sheet( materials, surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"), + manual=manual_prices, ) + # 사급·수동 단가 자재 → 본체 「자재(사급)」 줄(이중계상 가드 · PLAN 1장 Ⓐ-2). + from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import raise_material_rows + + raise_material_rows(result, unit_prices, next_number, bill_line) # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py new file mode 100644 index 00000000..62d6b98e --- /dev/null +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Materials.py @@ -0,0 +1,120 @@ +"""B09 원가계산 — 사급 자재총괄 줄을 **내역 본체 「자재」 줄**로 (PLAN 1장 Ⓐ-2 · 브레인 판정). + +셈부터 함(배정 프로젝트 자재총괄 9줄): ㉠ 일위대가를 거쳐 이미 내역에 선 자재 0 · ㉡ 일위대가에 안 +드는 자재 9 · ㉢ ㉠ 의 할증 0 — 품셈 표에 재료 줄이 없는 공종(규준틀·찰쌓기·타설)의 자재라 따로 +계상하는 것이 실무 모양. 본체 줄로 서므로 도급 재료비·계약·기성·집계가 고칠 곳 없이 그 줄을 셈. + + 올리는 줄 사급 · 수동 단가가 선 줄만(미확정 1건) — 관급은 수량표에만(도급 금액 밖) + ⚠ 이중계상 가드 — 같은 구조물 내역 줄의 일위대가 자재(붙은 것·못 붙은 것)와 겹치면 안 올리고 + 사유로 멈춤. **코드가 있으면 코드로, 없을 때만 이름으로**(명세 2장). 막는 쪽이라 틀려도 과소 + (줄이 안 서고 사유가 뜸) — 양식이 늘어 ㉠ 이 생기는 날 금액이 조용히 부풀지 않게. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Callable + +from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow +from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind +from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild + +MATERIAL_GROUP_NAME = "자재(사급)" +DOUBLE_COUNT_SUSPECT = "double_count_suspect" +_ZERO = Decimal(0) + + +def _flat(text: Any) -> str: + return "".join(str(text or "").split()) + + +def unit_price_materials(row: BillRow, unit_prices: UnitPriceBuild) -> list[tuple[str, str]]: + """그 내역 줄의 일위대가가 밟는 자재 — 붙은 것 (코드, 이름) · 못 붙은 줄 (「」, 글).""" + book = unit_prices.book + stack = [code for code in (row.price_code, *(code for code, _ in row.parts)) if code] + seen: set[str] = set() + found: list[tuple[str, str]] = [] + while stack: + code = stack.pop() + if code in seen: + continue + seen.add(code) + title = book.titles.get(code) + if title is not None and title.kind is PriceKind.MATERIAL: + if not code.startswith(FUEL_CODE_PREFIX): # 기계 연료는 자재총괄 자재가 아님 + found.append((code, title.name)) + if code.startswith("B-"): + labels = unit_prices.unattached.get(code[2:].split("#", 1)[0]) or [] + found.extend(("", label) for label in labels) + stack.extend(detail.ref_code for detail in book.details.get(code, [])) + return found + + +def overlap(name: str, code: str, inside: list[tuple[str, str]]) -> str: + """겹친 일위대가 자재 글 — 코드가 있으면 코드로, 없으면 이름(공백 무시 앞머리)으로.""" + if code: + return next((label for found, label in inside if found == code), "") + wanted = _flat(name) + return next((label for _, label in inside if wanted and _flat(label).startswith(wanted)), "") + + +def raise_material_rows( + result: BillResult, + unit_prices: UnitPriceBuild, + next_number: Callable[[str], str], + bill_line: Callable[[Money3, Decimal], Money3], +) -> None: + """자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만.""" + sheet = result.material_sheet + picked = [] + for item in getattr(sheet, "contractor_rows", []): + if item.amount_krw is None or not item.manual: + continue + inside = [ + pair + for row in result.rows + if not row.is_group and row.name in item.source_structure + for pair in unit_price_materials(row, unit_prices) + ] + hit = overlap(item.name, getattr(item, "code", ""), inside) + if hit: + reason = f"두 번 셀 수 있어 내역 줄로 안 올림 — 같은 구조물 일위대가에 「{hit}」" + item.note = f"{item.note} · {reason}" + result.missing.append( + { + "name": item.key, + "unit": item.unit, + "quantity": str(item.total_amount), + "reason": reason, + "blocked_kind": DOUBLE_COUNT_SUSPECT, + } + ) + continue + picked.append(item) + if not picked: + return + group_no = next_number("") + result.rows.append( + BillRow(item_no=group_no, level=1, code=None, name=MATERIAL_GROUP_NAME, is_group=True) + ) + for index, item in enumerate(picked, start=1): + price = item.unit_price_krw + line = bill_line(Money3(material=price), item.total_amount) + row = BillRow( + item_no=f"{group_no}-{index}", + level=2, + code=None, + name=item.name, + spec=item.spec, + unit=item.unit, + quantity=item.total_amount, + ) + row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO + row.unit_price_krw = price + row.amount_krw = line.total + row.material_krw = line.material + row.unconfirmed = 1 + row.add_note("quantity", f"자재총괄 할증 뒤 수량 — {', '.join(item.source_structure)}") + row.add_note("unit_price_krw", item.note) + result.unconfirmed.append({"name": row.name, "code": item.key, "count": 1}) + result.rows.append(row) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 74ce9347..0a3a4928 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -596,8 +596,14 @@ def pending_formula_note(code: str | None) -> str: return "" -def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: - """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.""" +def _material_row( + material: HandoffMaterial, result: BillResult, manual: dict | None = None +) -> BillRow: + """자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**. + + `manual` — 「자재 단가」 수동 단가(키 「이름 규격」). 사급 줄에 값이 있으면 빠진 목록에 안 올림 + (금액은 본체 「자재(사급)」 줄이 셈 — `BillOfQuantities_Materials`). + """ row = BillRow( item_no="", level=1, @@ -632,9 +638,14 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: "관급자재대(총원가 밖 별도 표기)로 갑니다.", ) reason = "관급 자재 단가 없음" + elif f"{material.material_name} {material.spec}".strip() in (manual or {}): + row.add_note( + "unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬" + ) + return row else: row.add_note( - "unit_price_krw", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + "unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기." ) reason = "사급 자재 단가 없음(미결 No.18)" diff --git a/B09_Estimation/B09_Estimation_MaterialPrices.py b/B09_Estimation/B09_Estimation_MaterialPrices.py index 8cf7fcca..5cfdf16c 100644 --- a/B09_Estimation/B09_Estimation_MaterialPrices.py +++ b/B09_Estimation/B09_Estimation_MaterialPrices.py @@ -185,33 +185,50 @@ def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> i return len(found) -def listing(build: Any, stored: Any) -> list[dict[str, Any]]: - """화면 줄 — 자원 축이 쓰는 자재(코드) + 저장만 있고 자원 축에서 사라진 줄. +def listing( + build: Any, stored: Any, materials: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄. - ⚠ 사라진 줄도 조용히 안 버림 — 「단가표에서 사라진 코드」로 보이고 사용자가 지움. + `materials` — B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 섬(관급은 도급 금액 밖). + ⚠ 사라진 줄도 조용히 안 버림 — 「사라진 자재」로 보이고 사용자가 지움. """ prices = normalize(stored) catalog = material_catalog_rows() uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {} rows: list[dict[str, Any]] = [] - for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): - info = catalog.get(code, {}) - entry = prices.get(code) or {} + + def add(key: str, shown: dict[str, Any], **extra: Any) -> None: + entry = prices.get(key) or {} rows.append( { - "key": code, - "name": info.get("name") or entry.get("name", ""), - "spec": info.get("spec") or entry.get("spec", ""), - "unit": info.get("unit") or entry.get("unit", ""), - "work_items": sorted(uses[code]), + "key": key, + **{ + field: shown.get(field) or entry.get(field, "") + for field in ("name", "spec", "unit") + }, "price_krw": entry.get("price_krw"), "source": entry.get("source", ""), "entered_at": entry.get("entered_at", ""), "missing": False, + **extra, } ) + + for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)): + add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price") + for material in materials or []: + key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip() + add( + key, + {**material, "name": material.get("material_name")}, + work_items=list(material.get("source_structure") or []), + origin="material_sheet", + supply_type=material.get("supply_type") or "", + quantity=str(material.get("total_amount")), + ) + listed = {row["key"] for row in rows} for key, entry in sorted(prices.items()): - if key in uses or not is_code_key(key): - continue - rows.append({"key": key, **entry, "work_items": [], "missing": True}) + if key not in listed: + rows.append({"key": key, **entry, "work_items": [], "missing": True}) return rows diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py index 135b36ff..53d5f2d6 100644 --- a/B09_Estimation/B09_Estimation_MaterialSheet.py +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -58,6 +58,9 @@ class MaterialSheetRow: surcharge_pct: Decimal | None = None source_structure: tuple[str, ...] = () note: str = "" + #: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정). + key: str = "" + manual: bool = False def as_dict(self) -> dict[str, Any]: def money(value: Decimal | None) -> str | None: @@ -75,6 +78,8 @@ class MaterialSheetRow: "surcharge_pct": money(self.surcharge_pct), "source_structure": list(self.source_structure), "note": self.note, + "key": self.key, + "manual": self.manual, } @@ -119,8 +124,14 @@ def build_material_sheet( *, surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, catalog: MaterialCatalog | None = None, + manual: dict[str, dict[str, str]] | None = None, ) -> MaterialSheet: - """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**. + + `manual` — 「자재 단가」 탭 수동 단가(`MaterialPrices.normalize`, 키 「이름 규격」). + ⚠ **사급 단가는 수동 단가만**(2026-09-14 PLAN 1장 Ⓐ-2) — 나라장터는 관급 단가라 사급에 쓰면 + 도급 재료비가 관급 값으로 조용히 섬. 관급 줄은 종전대로 나라장터(총원가 밖). + """ book = catalog or load_material_catalog() sheet = MaterialSheet() @@ -141,6 +152,7 @@ def build_material_sheet( surcharge_pct=getattr(material, "surcharge_pct", None), source_structure=tuple(getattr(material, "source_structure", ()) or ()), ) + row.key = f"{row.name} {row.spec}".strip() if row.supply_type == SUPPLY_UNKNOWN: # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. @@ -151,19 +163,33 @@ def build_material_sheet( ) continue - found = book.resolve(row.name, row.spec) - if found is None: + entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None + found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None + price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None + if price is None: row.note = ( - "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " - "6번 슬롯(적용 단가) 수동 입력 대기." + "사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)" + if row.supply_type == SUPPLY_CONTRACTOR + else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음" ) sheet.missing.append( {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} ) else: - row.unit_price_krw = found.price_krw + row.unit_price_krw = price # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. - row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW) + if entry: + row.manual = True + row.note = " · ".join( + part + for part in ( + "⚠ 수동 단가(미확정)", + entry.get("source"), + entry.get("entered_at"), + ) + if part + ) if row.supply_type == SUPPLY_OWNER: sheet.owner_rows.append(row) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 041a9477..e97a7adb 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -503,10 +503,15 @@ async def get_bill(project_id: UUID) -> JSONResponse: from common_util.common_util_project_settings import estimation_settings root = await _project_root_of(project_id) - rates = normalize((estimation_settings(root) if root else {}).get("edits")).get( - "bill_rates" + stored = estimation_settings(root) if root else {} + rates = normalize(stored.get("edits")).get("bill_rates") + result = build_bill( + payload, + build=build, + structure_prices=structure_prices, + rates=rates, + material_prices=stored.get("material_prices"), ) - result = build_bill(payload, build=build, structure_prices=structure_prices, rates=rates) except DoubleCountError as error: # 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다. logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error) diff --git a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py index 234c9491..3becbfb0 100644 --- a/B09_Estimation/B09_Estimation_Router_MaterialPrices.py +++ b/B09_Estimation/B09_Estimation_Router_MaterialPrices.py @@ -42,16 +42,25 @@ class MaterialPriceRequest(BaseModel): async def _rows(project_id: UUID) -> dict[str, Any]: + import json + + from B08_Quantity.B08_Quantity_Router_Material import get_handoff from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of from common_util.common_util_project_settings import estimation_settings root = await _project_root_of(project_id) settings = estimation_settings(root) if root else {} build = await _build_for(project_id) - rows = listing(build, settings.get(MATERIAL_PRICES_KEY)) + # 자재총괄 줄(「이름 규격」 키) — 인계를 못 받으면 코드 줄만 서고 그 사실을 `handoff_note` 로. + handoff = json.loads(bytes((await get_handoff(project_id)).body).decode("utf-8")) + materials = handoff.get("materials") + rows = listing(build, settings.get(MATERIAL_PRICES_KEY), materials) return { "status": "success", "rows": rows, + "handoff_note": "" + if materials is not None + else f"B08 인계를 못 받아 자재총괄 줄이 빠짐 — {handoff.get('message') or ''}", "unconfirmed_count": sum(1 for row in rows if row.get("price_krw")), } diff --git a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts index 4c6e66e5..42b2da13 100644 --- a/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts +++ b/B09_Estimation/B09_Estimation_UI_Tab_MaterialPrices.ts @@ -24,6 +24,10 @@ interface MaterialPriceRow { source: string; entered_at: string; missing: boolean; + /** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */ + origin?: string; + supply_type?: string; + quantity?: string; } interface MaterialPricesDto { @@ -31,6 +35,23 @@ interface MaterialPricesDto { message?: string; rows: MaterialPriceRow[]; unconfirmed_count: number; + handoff_note?: string; +} + +const SUPPLY_LABELS: Record = { + contractor_supplied: "사급", + owner_supplied: "관급 — 도급 금액 밖(수량만)", + unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)", +}; + +function whereText(row: MaterialPriceRow): string { + if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움"; + if (row.origin === "material_sheet") { + return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${ + SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? "" + }`; + } + return `일위대가 · ${row.work_items.join(", ")}`; } interface Change { @@ -75,11 +96,11 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v } const { wrap, tbody } = plainTable([ - "코드", + "코드·키", "명칭", "규격", "단위", - "쓰인 공종", + "쓰이는 곳", "단가(원)", "출처", "넣은 날", @@ -90,6 +111,13 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v price.inputMode = "decimal"; price.title = row.price_krw ? won(row.price_krw) : ""; const source = input(row.source, "160px", "견적 업체·물가지 쪽"); + // 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게). + const locked = + row.origin === "material_sheet" && + row.supply_type !== "contractor_supplied" && + !row.price_krw; + price.disabled = locked; + source.disabled = locked; const priceCell = el("td", "b09s-num"); priceCell.append(price); const sourceCell = el("td"); @@ -99,13 +127,7 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v el("td", "", row.name), el("td", "", row.spec), el("td", "", row.unit), - el( - "td", - "b09s-note", - row.missing - ? "⚠ 단가표에서 사라진 코드 — 단가를 비우고 저장해 지움" - : row.work_items.join(", "), - ), + el("td", "b09s-note", whereText(row)), priceCell, sourceCell, el("td", "", row.entered_at), @@ -147,10 +169,14 @@ function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): v }); ctx.body.append(bar, wrap); - if (data.rows.length === 0) ctx.body.append(hint("일위대가가 쓰는 자재 코드가 없음")); + if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true)); + if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음")); ctx.body.append( hint( - "수동 단가는 6번 슬롯(적용 단가)으로 서고, 닿은 내역 줄마다 「미확정」으로 셈 — 자재단가대비표에도 보임", + "일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임", + ), + hint( + "자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖", ), hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), ); diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json index ce84dbd3..31b2bde7 100644 --- a/resources/data_material_surcharge/_manifest.json +++ b/resources/data_material_surcharge/_manifest.json @@ -6,8 +6,8 @@ "files": [ { "file": "material_surcharge_2026-01-01.json", - "sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc", - "size_bytes": 4724 + "sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f", + "size_bytes": 5087 } ] } diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json index 3451db13..2e732b1e 100644 --- a/resources/data_material_surcharge/material_surcharge_2026-01-01.json +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -149,18 +149,20 @@ { "material": "막자갈", "rate": 4, - "pumsem": "const", + "pumsem": "forest", + "listed_in": "산림청고시 제2025-82호 1-3-1 3호 「부순돌ㆍ자갈ㆍ막자갈 4」(건설품셈도 같은 조건)", "listed_condition": "노상 및 노반재료(선택층·보조기층·기층)", "our_usage": "돌쌓기 뒤채움", - "why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음" + "why_not_applied": "조문이 쓰임을 노반재료로 못박아 뒤채움에 그 상한을 끌어올 근거가 없음(2026-09-14 브레인 판정 보류 유지 · 사방기술교본도 할증률 없음)" } ] }, "not_found": { "note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.", - "materials": ["야면석", "고임돌", "물구멍관"], + "materials": ["야면석", "고임돌", "물구멍관", "못"], "checked": [ "산림품셈 1-3-1 전 19종", + "못 — 산림청고시 제2025-82호 1-3-1 6호 기타재료에 없음(2026-09-14 브레인 확인)", "건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)", "건설품셈 제7장 돌공사 — 재료 할증률표 없음" ] diff --git a/resources/tester/test_b08_frame_material.py b/resources/tester/test_b08_frame_material.py index 3f2c85aa..1a1e5736 100644 --- a/resources/tester/test_b08_frame_material.py +++ b/resources/tester/test_b08_frame_material.py @@ -33,7 +33,22 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402 def 이름별(rows: list[dict]) -> dict: - return {(row["source"], row["name"]): row for row in rows} + return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows} + + +def test_이름과_규격을_가른다() -> None: + """이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로.""" + from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table + + rows = frame_material_rows(규준틀) + assert {(row["name"], row["spec"]) for row in rows} == { + ("각재", "50×50"), + ("판재", "T12"), + ("못", ""), + } + table = build_table({"structures": []}, extra_materials=rows) + rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]} + assert rates == {"각재 50×50": 5, "판재 T12": 10, "못": None} def test_개소가_서면_재료도_선다() -> None: diff --git a/resources/tester/test_b08_handoff.py b/resources/tester/test_b08_handoff.py index 016d73dc..201dc864 100644 --- a/resources/tester/test_b08_handoff.py +++ b/resources/tester/test_b08_handoff.py @@ -552,11 +552,12 @@ def test_자재가_없으면_not_applied() -> None: assert handoff["surcharge_status"] == "not_applied" -def test_관급구분_미분류가_계약값임() -> None: - """`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다.""" +def test_관급구분_안_정하면_기본_사급() -> None: + """2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함).""" unit = build_unit_table([구조물()]) handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit)) - assert all(row["supply_type"] == "unknown" for row in handoff["materials"]) + assert handoff["materials"] + assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"]) # ── 운반 줄 (2026-09-07 조율 창 요청) ─────────────────────────────── diff --git a/resources/tester/test_b08_material_summary.py b/resources/tester/test_b08_material_summary.py index 98d71ebc..53ccd9f8 100644 --- a/resources/tester/test_b08_material_summary.py +++ b/resources/tester/test_b08_material_summary.py @@ -103,7 +103,7 @@ def test_품셈에_포함된_항목은_또_붙이지_않음() -> None: table = build_table(원단위표(성분("모래", "㎥", 10.0, surcharge_included=True))) row = 줄(table, "모래") assert row["total_amount"] == pytest.approx(10.0) - assert row["note"] == NOTE_INCLUDED + assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」 # ── destination 가르기 ────────────────────────────────────────────── @@ -214,7 +214,7 @@ def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None: table = build_table(원단위표(성분("낯선자재", "㎥", 10.0))) row = 줄(table, "낯선자재") assert row["surcharge_pct"] is None - assert row["note"] == NOTE_RATE_MISSING + assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」 assert "낯선자재" in table["missing_rate_materials"] # 값은 잃지 않는다 — 순수량 그대로 둔다. assert row["total_amount"] == pytest.approx(10.0) @@ -252,13 +252,40 @@ def test_합계는_반올림하지_않음() -> None: # ── 관급/사급 ─────────────────────────────────────────────────────── -def test_안_정한_자재는_미정으로_드러남() -> None: - """⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼 - 읽힌다(2026-09-09 화면 확인). 값(`unknown`)과 판정은 그대로다.""" +def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None: + """2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느 + 합계에도 안 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 줄은 그대로 미정.""" table = build_table(원단위표(성분("야면석", "㎥", 5.0))) - assert 줄(table, "야면석")["supply"] == SUPPLY_UNKNOWN - assert 줄(table, "야면석")["supply_label"] == "미정(발주기관 결정)" - assert table["missing_supply_materials"] == ["야면석"] + assert 줄(table, "야면석")["supply"] == "contractor_supplied" + assert "기본 사급" in 줄(table, "야면석")["note"] + assert table["missing_supply_materials"] == [] + picked = build_table(원단위표(성분("야면석", "㎥", 5.0)), supply_map={"야면석": "unknown"}) + assert 줄(picked, "야면석")["supply"] == SUPPLY_UNKNOWN + assert 줄(picked, "야면석")["supply_label"] == "미정(발주기관 결정)" + assert picked["missing_supply_materials"] == ["야면석"] + + +def test_율이_없는_까닭이_줄에_뜸() -> None: + """보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임.""" + table = build_table(원단위표(성분("막자갈", "㎥", 10.0), 성분("못", "㎏", 1.0))) + assert 줄(table, "막자갈")["surcharge_pct"] is None + assert "조건이 다름 — 사용자 확정 후 옮길 것" in 줄(table, "막자갈")["note"] + assert "할증률표에 이름 없음" in 줄(table, "못")["note"] + + +def test_할증률은_표값_이내에서만_고름() -> None: + """1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀.""" + fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}}) + unit = 원단위표(성분("판재", "㎥", 1.0), 성분("낯선자재", "㎥", 1.0)) + lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3}) + assert 줄(lower, "판재")["surcharge_pct"] == 6 and 줄(lower, "판재")["surcharge_cap"] == 10 + assert "표 상한 10% 이내" in 줄(lower, "판재")["note"] + assert 줄(lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀 + over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12}) + assert ( + 줄(over, "판재")["surcharge_pct"] == 10 + and "이내가 아니라 안 씀" in 줄(over, "판재")["note"] + ) def test_설정이_정한_구분을_따름() -> None: diff --git a/resources/tester/test_b09_material_prices.py b/resources/tester/test_b09_material_prices.py index 2524a8d2..2ece9f5f 100644 --- a/resources/tester/test_b09_material_prices.py +++ b/resources/tester/test_b09_material_prices.py @@ -80,13 +80,85 @@ def test_내역_줄은_미확정으로_선다() -> None: assert not plain.unconfirmed +def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict: + return { + "material_name": name, + "spec": spec, + "unit": "㎥", + "net_amount": total, + "total_amount": total, + "supply_type": supply, + "source_structure": source, + } + + +def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None: + """Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈.""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + + payload = { + "work_items": [], + "materials": [ + _자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]), + _자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]), + _자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]), + ], + } + prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}} + result = build_bill(payload, build=cached_build(), material_prices=prices) + group = next(r for r in result.rows if r.name == "자재(사급)") + rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")] + assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림 + assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000 + assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460") + assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing) + sheet = result.material_sheet + assert ( + sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual + ) # 관급엔 수동 단가 안 씀 + plain = build_bill(payload, build=cached_build()) + assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0 + + +def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None: + """이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소).""" + from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill + from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap + + built = cached_build(material_prices=((STAKE, "1500", ""),)) + unit = built.book.titles["B-FP-05-15"].unit + payload = { + "work_items": [ + {"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10} + ], + "materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])], + } + result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}}) + assert not any(r.name == "자재(사급)" for r in result.rows) + hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect") + assert "말뚝" in hit["reason"] + inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")] + assert overlap("시멘트", "", inside).startswith("시멘트") + assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만 + + def test_목록은_자원_축_자재와_사라진_저장_줄() -> None: built = cached_build() stored = { STAKE: {"price_krw": "1500"}, "AR-M-00000000": {"price_krw": "7", "name": "옛 자재"}, } - rows = {row["key"]: row for row in listing(built, stored)} + materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])] + rows = { + row["key"]: row for row in listing(built, {**stored, "못": {"price_krw": "3"}}, materials) + } assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500" assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림 assert rows["AR-M-b0853497"]["price_krw"] is None + sheet_row = rows["각재 50×50"] + assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == ( + "각재", + "50×50", + "material_sheet", + ) + assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True