From 2e9e0d1f4aff6f9a416f47710e1de6e7284b2d81 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:43:28 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(B09):=20=EB=A7=89=ED=9E=98=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EB=A5=BC=20=EA=B0=88=EB=9E=98=EB=B3=84=EB=A1=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B0=80=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B08 이 `blocked_reason`(사용자 말 문구) + `blocked_kind` 를 실어 보내기 시작함 - 문구는 **B08 것을 그대로** 씀 — 두 벌로 짜면 한쪽만 고쳐지는 자리가 됨 - 화면 미확보 목록을 두 갈래로 가름 · 「입력하면 풀리는 것 — 설계 화면에서 값을 고르면 금액이 섭니다」(`input_missing`) · 「우리가 만들어야 하는 것 — 원단위·전개식이 아직 없습니다」 - 한 목록에 섞이면 사용자가 「후보를 고르면 되나」로 잘못 읽음(돌쌓기가 그 자리였음) - ⚠ 빈 값은 오류가 아니라 **「아직 안 고른 상태」** — 랩탑이 「— 선택 —」 빈 칸으로 열어 둔 것과 짝임(첫 항목을 슬쩍 고르면 근거 없는 값이 단가로 흘러감) 계약 시험의 측점 까닭 보강 — 「지금은 안 씀 — 구간별 산출근거를 낼 때 쓸 것」 검증: pytest 195 통과, tsc 0건 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 31 ++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 37 +++++++++++++++---- ui_template/ui_template_locale_b2.ts | 8 ++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 3c42e1ac..a86d7f48 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -39,6 +39,13 @@ _ZERO = Decimal(0) #: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. SUPPLY_UNKNOWN = "unknown" +#: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.** +_BLOCKED_LABELS = { + "input_missing": "입력이 필요합니다", + "unit_data_missing": "원단위가 없습니다(우리가 만들 것)", + "formula_missing": "전개식이 없습니다(우리가 만들 것)", +} + class BillError(ValueError): """내역서를 세울 수 없는 경우. 빈 표를 돌려주지 않고 멈춘다.""" @@ -72,6 +79,11 @@ class HandoffWorkItem: #: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다. composite_not_ready: tuple = () structure_kind: str = "" + #: B08 이 적어 보낸 막힘 사유 — **문구는 B08 것을 그대로 쓴다**(두 벌로 짜지 않는다). + blocked_reason: str = "" + #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / + #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. + blocked_kind: str = "" @property def display_name(self) -> str: @@ -209,6 +221,8 @@ 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 "", ) for row in payload["work_items"] ] @@ -539,6 +553,23 @@ def _leaf_row( result.excluded.append(row) return row + if item.blocked_reason: + # B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다. + # 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을 + # 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다. + row.note = f"{_BLOCKED_LABELS.get(item.blocked_kind, '막힘')} — {item.blocked_reason}" + result.missing.append( + { + "name": row.name, + "code": node.code, + "unit": row.unit, + "quantity": str(item.quantity), + "reason": row.note, + "blocked_kind": item.blocked_kind, + } + ) + return row + price_code = f"B-{node.code}" if price_code not in unit_prices.book.titles: # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 5dd1e8fe..a40987a1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -668,7 +668,13 @@ interface BillDto { rows: number; detail_rows: number; body_total_krw: string; - missing: Array<{ name: string; reason: string; unit?: string; quantity?: string }>; + missing: Array<{ + name: string; + reason: string; + unit?: string; + quantity?: string; + blocked_kind?: string; + }>; notes: string[]; }; } @@ -872,13 +878,30 @@ export async function renderB09Estimation(root: HTMLElement): Promise { note.className = "b09-hint"; note.textContent = `${L("B09_Estimation_Boq_Missing")} (${bill.summary.missing.length})`; body.append(note); - const list = document.createElement("ul"); - for (const item of bill.summary.missing) { - const li = document.createElement("li"); - li.textContent = `${item.name} — ${item.reason}`; - list.append(li); + + // ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과 + // 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다. + const needsInput = bill.summary.missing.filter( + (item) => item.blocked_kind === "input_missing", + ); + const rest = bill.summary.missing.filter((item) => item.blocked_kind !== "input_missing"); + for (const [labelKey, group] of [ + ["B09_Estimation_Boq_NeedsInput", needsInput], + ["B09_Estimation_Boq_NeedsWork", rest], + ] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) { + if (group.length === 0) continue; + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${L(labelKey)} (${group.length})`; + body.append(head); + const list = document.createElement("ul"); + for (const item of group) { + const li = document.createElement("li"); + li.textContent = `${item.name} — ${item.reason}`; + list.append(li); + } + body.append(list); } - body.append(list); } if (bill.materials.length > 0) { diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 038fb415..40e7a78a 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -679,6 +679,14 @@ export const ui_locales_b2 = { "검산용 줄 — 수량만 보이고 금액을 매기지 않습니다", "Check rows — quantity only, never priced", ], + B09_Estimation_Boq_NeedsInput: [ + "입력하면 풀리는 것 — 설계 화면에서 값을 고르면 금액이 섭니다", + "Waiting on input — pick the value on the design screen and the amount appears", + ], + B09_Estimation_Boq_NeedsWork: [ + "우리가 만들어야 하는 것 — 원단위·전개식이 아직 없습니다", + "Needs build — unit data or formula is missing", + ], B09_Estimation_Boq_Missing: [ "금액을 못 세운 줄 — 0 으로 채우지 않고 그대로 보입니다", "Rows without an amount — shown as-is, not zero-filled", From d8cbd48baf6b3cb385dbfd930e6fd6591c90ec4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:51:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B09):=20=E2=91=A2=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EC=84=9C(D=EC=B8=B5)=20=EC=8B=A0=EC=84=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=82=B4=EC=97=AD=20=EC=A4=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=82=B0=20=EA=B2=BD=EB=A1=9C=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실무 내역서는 줄마다 비고에 「단산 46 참조」를 적고 그 산출서를 펴서 검산함(8-13). STC 실측도 `D` 가 `B`(일위대가)를 참조하는 한 층 위였음(9-3) - `PriceBook` 의 「제목 + 상세」 한 쌍에 `kind=PRICE_BASIS` 만 얹음 — 표를 세 벌 만들지 않음. 화면도 일위대가 탭과 **같은 2단 모양** - 번호는 **코드에 안 박음** — 실무 참조번호는 그 내역서 안의 차례라 프로젝트마다 다름. 코드는 공종을 가리키고 번호는 조판할 때 매김 - 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**, 두 줄이 같은 번호를 가리킴 - 내역 줄 비고에 「단산 N 참조」를 달음 - 지금은 일위대가를 그대로 한 줄로 참조 — 할증·기타 비용이 붙을 자리를 미리 열어 둠 - `GET …/estimation/price-basis/{code}` 로 본표 조회 화면 실측: 내역 줄 「2-2-1 측구터파기 488,926 / 단산 1 참조」, 산출서 탭에 1장(토사 FP-09-12-01 ㎥ 5,288.6), 눌러 본표·참조 코드 확인 검증: pytest 195 통과, tsc 0건 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 20 +++ B09_Estimation/B09_Estimation_PriceBasis.py | 165 ++++++++++++++++++ B09_Estimation/B09_Estimation_Router.py | 17 ++ B09_Estimation/B09_Estimation_UI_Page.ts | 79 ++++++++- ui_template/ui_template_locale_b2.ts | 6 + 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 B09_Estimation/B09_Estimation_PriceBasis.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index a86d7f48..fc760edc 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -163,9 +163,13 @@ class BillResult: missing: list[dict[str, str]] = field(default_factory=list) #: `in_bill=false` 라 금액을 안 매긴 줄(보정량계 등). 수량은 보이되 합계에 안 든다. excluded: list[BillRow] = field(default_factory=list) + #: 이 내역서에 쓰인 일위대가 코드 — ③ 단가산출서 번호를 매기는 차례가 된다. + used_unit_prices: list[str] = field(default_factory=list) #: 자재 벌 — 공급 구분이 갈린 것만 금액이 선다. material_rows: list[BillRow] = field(default_factory=list) notes: list[str] = field(default_factory=list) + #: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다. + price_basis: Any = None @property def direct_material_krw(self) -> Decimal: @@ -422,6 +426,18 @@ def build_bill( total_cut_volume_m3=cut_total, ) + # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). + from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis + + sheet = build_price_basis(result.used_unit_prices, unit_prices) + for row in result.rows: + if row.is_group or row.code is None or row.amount_krw is None: + continue + entry = sheet.by_unit_price(f"B-{row.code}") + if entry is not None: + row.note = " / ".join(part for part in (entry.label, row.note) if part) + result.price_basis = sheet + if any(m.surcharge_pct is None for m in materials): result.notes.append( "자재 할증률이 아직 없습니다 — 할증 전 값으로 섰습니다. " @@ -651,6 +667,10 @@ def _leaf_row( if part ) + # 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다. + if price_code not in result.used_unit_prices: + result.used_unit_prices.append(price_code) + unit_money = unit_prices.book.resolve(price_code) line = unit_money.scaled(item.quantity) row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) diff --git a/B09_Estimation/B09_Estimation_PriceBasis.py b/B09_Estimation/B09_Estimation_PriceBasis.py new file mode 100644 index 00000000..dc35bb2b --- /dev/null +++ b/B09_Estimation/B09_Estimation_PriceBasis.py @@ -0,0 +1,165 @@ +"""B09 원가계산 — ③ 단가산출서 `D` 층 (PLAN 9-1 · 9-3). + +**무엇인가** — 내역서 한 줄의 단가가 **어떻게 나왔는지** 보이는 표다. 실무 내역서는 +줄마다 비고에 「단산 46 참조」처럼 **참조번호**를 적고, 그 번호의 산출서를 펴서 검산한다 +(8-13 관측). STC 실측도 `D01341 절토(토사) 굴삭기0.7㎥ m³ 1,939` 처럼 **`D` 가 `B` +(일위대가)를 참조하는 한 층 위**였다. + + D 단가산출 → B 일위대가 → X 시간당 사용료 → S·M·L 카탈로그 + +⚠ **표를 세 벌 만들지 않는다** (PLAN 9-3). `PriceBook` 의 「제목 + 상세」 한 쌍에 +`kind` 만 `PRICE_BASIS` 로 얹는다 — 일위대가와 같은 구조, 같은 화면 모양이다. + +⚠ **번호는 코드에 박지 않는다.** 실무 참조번호(「단산 46」)는 **그 내역서 안에서의 +차례**라 프로젝트마다 다르다. 코드(`D-FP-…`)는 공종을 가리키고, 번호는 조판할 때 매긴다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_PriceBook import PriceDetail, PriceKind, PriceTitle +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at +from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build + +_ONE = Decimal(1) + + +@dataclass +class PriceBasisEntry: + """단가산출서 한 장 — 참조번호 + 그 줄이 무엇을 참조하는지.""" + + number: int + code: str + name: str + spec: str + unit: str + unit_price_krw: Decimal + #: 이 산출서가 참조하는 일위대가 코드. 화면에서 눌러 내려가는 자리. + ref_code: str + + @property + def label(self) -> str: + """내역서 비고에 적는 문구 — 실무 서식 그대로 「단산 46 참조」.""" + return f"단산 {self.number} 참조" + + def as_dict(self) -> dict[str, Any]: + return { + "number": self.number, + "label": self.label, + "code": self.code, + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "unit_price_krw": str(self.unit_price_krw), + "ref_code": self.ref_code, + } + + +@dataclass +class PriceBasisSheet: + """그 내역서에 딸린 단가산출서 한 벌.""" + + entries: list[PriceBasisEntry] = field(default_factory=list) + + def by_unit_price(self, ref_code: str) -> PriceBasisEntry | None: + return next((entry for entry in self.entries if entry.ref_code == ref_code), None) + + def as_dict(self) -> dict[str, Any]: + return {"entries": [entry.as_dict() for entry in self.entries]} + + +def build_price_basis( + unit_price_codes: list[str], + build: UnitPriceBuild | None = None, +) -> PriceBasisSheet: + """내역서에 쓰인 일위대가마다 산출서 한 장을 세운다. + + 번호는 **쓰인 차례**로 매긴다 — 실무 참조번호가 그 내역서 안의 차례이기 때문이다. + 같은 일위대가가 두 줄에 쓰이면 **산출서는 한 장**이고 두 줄이 같은 번호를 가리킨다. + """ + prices = build or cached_build() + sheet = PriceBasisSheet() + seen: set[str] = set() + + for code in unit_price_codes: + if not code or code in seen or code not in prices.book.titles: + continue + seen.add(code) + title = prices.book.title(code) + money = prices.book.resolve(code) + basis_code = f"D-{code[2:]}" if code.startswith("B-") else f"D-{code}" + + if basis_code not in prices.book.titles: + prices.book.add_title( + PriceTitle( + code=basis_code, + kind=PriceKind.PRICE_BASIS, + name=title.name, + spec=title.spec, + unit=title.unit, + ) + ) + # ⚠ 지금은 **일위대가를 그대로 한 줄로** 참조한다. 할증·기타 비용이 붙는 + # 자리가 생기면 여기에 줄이 는다 — 구조를 미리 열어 둔다. + prices.book.add_detail(PriceDetail(basis_code, code, _ONE, note="일위대가 그대로")) + + sheet.entries.append( + PriceBasisEntry( + number=len(sheet.entries) + 1, + code=basis_code, + name=title.name, + spec=title.spec, + unit=title.unit, + unit_price_krw=round_at(money.total, OutputPlace.UNIT_PRICE_ROW), + ref_code=code, + ) + ) + return sheet + + +def price_basis_detail( + code: str, + build: UnitPriceBuild | None = None, +) -> dict[str, Any]: + """산출서 한 장의 본표 — 무엇을 참조해 그 단가가 나왔는지. + + 일위대가 본표와 **같은 모양**이라 화면이 같은 표를 쓴다. + """ + from B09_Estimation.B09_Estimation_UnitPrice import detail_of + + prices = build or cached_build() + title = prices.book.title(code) + rows: list[dict[str, Any]] = [] + for detail in prices.book.details.get(code, []): + child = prices.book.title(detail.ref_code) + money = prices.book.resolve(detail.ref_code).scaled(detail.quantity) + rows.append( + { + "code": detail.ref_code, + "name": child.name, + "spec": child.spec, + "unit": child.unit, + "quantity": str(detail.quantity), + "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "drillable": True, + "note": detail.note, + } + ) + + money = prices.book.resolve(code) + return { + "code": code, + "name": title.name, + "spec": title.spec, + "unit": title.unit, + "rows": rows, + "total": str(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)), + "material": str(money.material), + "labor": str(money.labor), + "expense": str(money.expense), + # 한 층 아래(일위대가) 본표를 그대로 딸려 보낸다 — 화면이 두 번 물어보지 않게. + "unit_price": detail_of(prices, rows[0]["code"]) if rows else None, + } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 9531369b..bf58a57c 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -296,5 +296,22 @@ async def get_bill(project_id: UUID) -> JSONResponse: "excluded": [row.as_dict() for row in result.excluded], "materials": [row.as_dict() for row in result.material_rows], "summary": bill_summary(result), + "price_basis": result.price_basis.as_dict() if result.price_basis else {"entries": []}, } ) + + +@router.get("/{project_id}/estimation/price-basis/{code}") +async def get_price_basis_detail(project_id: UUID, code: str) -> JSONResponse: + """③ 단가산출서 한 장 — 그 단가가 무엇을 참조해 나왔는지.""" + from B09_Estimation.B09_Estimation_PriceBasis import price_basis_detail + + try: + body = price_basis_detail(code) + except Exception: + logger.exception("B09 단가산출서 조회 실패: project_id=%s, code=%s", project_id, code) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "그 단가산출서를 찾지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **body}) diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index a40987a1..8f5e3fe1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -549,7 +549,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], ["boq", "B09_Estimation_Tab_Boq", true], ["unit_price", "B09_Estimation_Tab_UnitPrice", true], - ["price_basis", "B09_Estimation_Tab_PriceBasis", false], + ["price_basis", "B09_Estimation_Tab_PriceBasis", true], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], ["supply", "B09_Estimation_Tab_Supply", false], @@ -660,6 +660,17 @@ interface BillRowDto { note: string; } +interface PriceBasisEntryDto { + number: number; + label: string; + code: string; + name: string; + spec: string; + unit: string; + unit_price_krw: string; + ref_code: string; +} + interface BillDto { rows: BillRowDto[]; excluded: BillRowDto[]; @@ -677,6 +688,7 @@ interface BillDto { }>; notes: string[]; }; + price_basis: { entries: PriceBasisEntryDto[] }; } /** @@ -727,6 +739,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise { let unitPriceDetail: UnitPriceDetailDto | null = null; let selectedUnitPrice: string | null = null; let bill: BillDto | null = null; + let priceBasis: string | null = null; const main = document.createElement("div"); main.className = "b09-main"; @@ -914,6 +927,66 @@ export async function renderB09Estimation(root: HTMLElement): Promise { } }; + /** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */ + const drawPriceBasisTab = (): void => { + if (!bill) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_PB_Empty"); + body.append(empty); + return; + } + const entries = bill.price_basis?.entries ?? []; + // 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌 + // 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다). + const split = document.createElement("div"); + + const list = document.createElement("table"); + list.className = "b09-sheet b09-up-list"; + list.innerHTML = "번호공종단위단가"; + const tbody = document.createElement("tbody"); + for (const entry of entries) { + const tr = document.createElement("tr"); + for (const text of [ + String(entry.number), + `${entry.name} ${entry.spec}`.trim(), + entry.unit, + entry.unit_price_krw, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + tr.style.cursor = "pointer"; + if (entry.code === priceBasis) tr.style.fontWeight = "600"; + tr.addEventListener("click", () => { + priceBasis = entry.code; + drawBody(); + }); + tbody.append(tr); + } + list.append(tbody); + split.append(list); + + const picked = entries.find((entry) => entry.code === priceBasis) ?? null; + const panel = document.createElement("div"); + panel.className = "b09-up-detail"; + if (picked === null) { + panel.textContent = L("B09_Estimation_PB_Pick"); + } else { + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`; + const ref = document.createElement("div"); + ref.className = "b09-hint"; + // 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다. + ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`; + panel.append(head, ref); + } + split.append(panel); + body.append(split); + }; + const drawBody = (): void => { body.replaceChildren(); if (activeTab === "unit_price") { @@ -924,6 +997,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawBoqTab(); return; } + if (activeTab === "price_basis") { + drawPriceBasisTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 40e7a78a..e8426f88 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -671,6 +671,12 @@ export const ui_locales_b2 = { B09_Estimation_Tab_CostSheet: ["공사원가계산서", "Cost Statement"], B09_Estimation_Tab_Boq: ["설계내역서", "Bill of Quantities"], B09_Estimation_Boq_Total: ["내역서 합계", "Bill total"], + B09_Estimation_PB_Empty: [ + "설계내역서를 먼저 불러오면 단가산출서가 섭니다.", + "Load the bill first and the price-basis sheets appear.", + ], + B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], + B09_Estimation_PB_Ref: ["참조", "Refers to"], B09_Estimation_Boq_Precision: [ "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", From db0b38d826127fd6ddee0c779c2c4df9eb5d709c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 04:56:51 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(B09):=20=EA=B0=88=EB=9E=98=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=AA=BD=EC=97=90=EC=84=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A6=20=E2=80=94=20=EB=AC=BC=EA=B2=B0=ED=91=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20+=20=EA=B5=AC=EA=B0=84=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 창 합의 — B08 은 **의미**만 보내고(`work_item_code` + 저장 제원 원본값), 품셈 원문 표기를 키로 옮기는 것은 **원문을 읽는 이쪽 몫** - 품셈이 물결표를 두 종류로 섞어 씀(`∼` U+223C 26건 · `~` U+FF5E 8건) — 같은 뜻인데 키가 두 벌이었음. 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존** - ⚠ 규칙은 둘뿐 — **내부 공백 제거 + 물결표 통일**. 키에 쓰인 글자를 세어 그 밖에는 소수점·괄호뿐임을 확인(하이픈·곱셈표 없음) - 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없어**(사이에 「㎝이상」이 낌) 글자 포함으로는 못 맞춤 → **수의 짝**으로 견줌 - 저장 제원이 한 값으로 오는 경우(뒷길이 45㎝)는 **그 값을 담는 가장 좁은 구간**을 고름 — 45 → `#55cm이하`, 25 → `#35cm이하` - ⚠ 담을 갈래가 없으면 `None` — **가까운 것을 임의로 고르지 않음**(95 → 없음) - 조판이 `variant_axis`·`variant_value` 를 받아 코드를 고르고, 못 고르면 종전대로 후보를 보임 검증: pytest 198 통과(신규 5 — 짝 시험 포함) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 19 ++++- B09_Estimation/B09_Estimation_UnitPrice.py | 77 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index fc760edc..72d85a48 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -31,7 +31,11 @@ from B09_Estimation.B09_Estimation_Guards import ( ) from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at -from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build +from B09_Estimation.B09_Estimation_UnitPrice import ( + UnitPriceBuild, + cached_build, + find_variant_code, +) _ZERO = Decimal(0) @@ -84,6 +88,9 @@ class HandoffWorkItem: #: 막힘 갈래 — `input_missing`(사용자가 입력하면 풀림) / #: `unit_data_missing`·`formula_missing`(우리가 만들어야 함). 할 일이 다르므로 가른다. blocked_kind: str = "" + #: 갈래 축·원본값 — 「stone_cm」·「60~80」. **키 문자열은 우리가 만든다**(두 창 합의). + variant_axis: str = "" + variant_value: str = "" @property def display_name(self) -> str: @@ -227,6 +234,8 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[ structure_kind=row.get("structure_kind") or "", blocked_reason=row.get("blocked_reason") or "", blocked_kind=row.get("blocked_kind") or "", + variant_axis=row.get("variant_axis") or "", + variant_value=str(row.get("variant_value") or ""), ) for row in payload["work_items"] ] @@ -587,6 +596,14 @@ def _leaf_row( return row price_code = f"B-{node.code}" + if item.variant_value and price_code not in unit_prices.book.titles: + # B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다. + # 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다. + picked = find_variant_code(node.code, item.variant_value, unit_prices) + if picked is not None: + price_code = picked + row.spec = f"{row.spec} {item.variant_value}".strip() + if price_code not in unit_prices.book.titles: # 한 층 아래에 일위대가가 있으면 **후보로 보여준다** — 임의로 고르지 않는다 # (CLAUDE.md 3장 「미결 항목 임의 확정 금지」, B08 `mapping_pending_user` 와 같은 태도). diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index f680097b..f60b44ac 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -224,6 +224,81 @@ def load_basis_missing( } +#: 품셈 원문이 섞어 쓰는 물결표 — 「직경40㎝이상∼60㎝미만」(U+223C)과 +#: 「직경40㎝이상~60㎝미만」(U+FF5E)이 **같은 뜻인데 키가 두 벌**이었다(2026-09-08 실측: +#: 13-6-1·2 는 ∼, 13-6-3 은 ~). 키에서만 한 종류로 모으고 **원문 문구는 이름에 보존**한다. +#: ⚠ 규칙은 둘뿐이다 — **내부 공백 제거 + 물결표 통일.** 다른 글자는 손대지 않는다 +#: (키에 쓰인 글자를 세어 보니 그 밖에는 소수점·괄호뿐이었다). +_TILDE_CHARS = "∼~〜~" + + +def normalize_variant_key(text: str) -> str: + """갈래 키 정규화 — 공백을 지우고 물결표를 한 종류(`~`)로 모은다.""" + tight = "".join(str(text).split()) + return "".join("~" if ch in _TILDE_CHARS else ch for ch in tight) + + +def find_variant_code( + work_item_code: str, + variant_value: str, + build: UnitPriceBuild | None = None, +) -> str | None: + """B08 이 보낸 **저장 제원 원본값**(「60~80」)을 내 갈래 코드로 옮긴다. + + 갈래 키는 품셈 원문에서 나오고 **그 원문을 읽는 쪽이 여기**다(2026-09-08 두 창 합의). + 못 맞추면 `None` — **가까운 갈래를 임의로 고르지 않는다.** + """ + prices = build or cached_build() + wanted = normalize_variant_key(variant_value) + if not wanted: + return None + + prefix = f"B-{work_item_code}#" + candidates = [code for code in prices.book.titles if code.startswith(prefix)] + for code in candidates: + if normalize_variant_key(code[len(prefix) :]) == wanted: + return code + # ⚠ 글자 포함으로는 안 맞는다 — 「60~80」은 「직경60㎝이상~80㎝미만」 **안에 없다** + # (사이에 「㎝이상」이 낀다). **수의 짝**으로 견준다: [60, 80] == [60, 80]. + numbers = _numbers_of(wanted) + if not numbers: + return None + hits = [ + code + for code in candidates + if _numbers_of(normalize_variant_key(code[len(prefix) :])) == numbers + ] + if len(hits) == 1: + return hits[0] + if len(numbers) == 1: + # 저장 제원이 **한 값**으로 온다(뒷길이 45㎝). 갈래는 구간이므로 그 값을 담는 + # 구간을 고른다 — 「45」 → 「55cm이하」. **가장 좁은 구간**을 고른다. + return _bracket_for(numbers[0], candidates, prefix) + return None + + +def _bracket_for(value: Decimal, candidates: list[str], prefix: str) -> str | None: + """그 값을 담는 갈래 — 「N 이하」는 상한, 「A 이상~B 미만」은 범위로 본다.""" + best: tuple[Decimal, str] | None = None + for code in candidates: + label = normalize_variant_key(code[len(prefix) :]) + bounds = _numbers_of(label) + if len(bounds) == 1: + if "이하" in label and value <= bounds[0]: + if best is None or bounds[0] < best[0]: + best = (bounds[0], code) + elif len(bounds) == 2 and bounds[0] <= value <= bounds[1]: + width = bounds[1] - bounds[0] + if best is None or width < best[0]: + best = (width, code) + return best[1] if best else None + + +def _numbers_of(text: str) -> list[Decimal]: + """그 문자열에 나오는 수들 — 「직경60㎝이상~80㎝미만」 → [60, 80].""" + return [Decimal(token) for token in re.findall(r"\d+(?:\.\d+)?", text)] + + def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -254,7 +329,7 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild: # 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로** # (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해 # 키에 공백을 남기면 한 칸 차이로 영영 안 맞는다. 공백 말고는 손대지 않는다. - variant_key = "".join(variant.split()) + variant_key = normalize_variant_key(variant) title_code = f"B-{work_item_code}" + (f"#{variant_key}" if variant_key else "") if title_code in build.book.titles: continue From e250d2f716b6f2154937c91a48d1a9b0ada73b11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:02:26 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(B09):=20=EC=9E=90=EC=9E=AC=EB=8C=80=20?= =?UTF-8?q?=ED=91=9C=20=EC=8B=A0=EC=84=A4=20=E2=80=94=20=EA=B4=80=EA=B8=89?= =?UTF-8?q?=C2=B7=EC=82=AC=EA=B8=89=C2=B7=EB=AF=B8=EC=A0=95=20=EC=85=8B?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B0=88=EB=9D=BC=20=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 8-7 「자재대·관급자재대(금액)는 B09」 — B08 자재총괄이 낸 수량·할증에 단가를 붙여 금액을 내는 자리. 수량은 B08 것이 정본이라 다시 세지 않음 - **관급은 총원가 밖 별도 표기**(⑤ 관급자재대와 같은 값), 사급은 도급 재료비 - ⚠ `unknown`(관급·사급 미정)은 **어느 합계에도 안 넣음** — 넣으면 총액이 틀리고 어느 쪽으로 넣었는지 나중에 못 가림. 지금 실물 4건이 전부 이 상태 - 단가가 없으면 **금액을 안 만들고** 사유를 남김(사급 물가지 미결 No.18) - 할증률이 없으면 「할증 전 수량」임을 표에 적음 — 여기서 또 곱하지 않음(㉠) - 관급자재대 합계는 **천원 올림**(단수처리 규칙) - 화면 탭 신설 — 세 무리를 각각 표로 보이고 합계·사유를 함께 냄 검증: pytest 205 통과(신규 5 — 합계 분리·미정 제외·단가 없음·할증 깃발·천원 올림), tsc 0건. 화면 확인은 공용 브라우저 세션 만료로 다음 차례에 Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 11 ++ .../B09_Estimation_MaterialSheet.py | 180 ++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Page.ts | 84 +++++++- ui_template/ui_template_locale_b2.ts | 13 ++ 4 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 B09_Estimation/B09_Estimation_MaterialSheet.py diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 72d85a48..9e798367 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -177,6 +177,8 @@ class BillResult: notes: list[str] = field(default_factory=list) #: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다. price_basis: Any = None + #: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」). + material_sheet: Any = None @property def direct_material_krw(self) -> Decimal: @@ -435,6 +437,14 @@ def build_bill( total_cut_volume_m3=cut_total, ) + # 자재대 — B08 수량·할증에 단가를 붙인다. 관급은 총원가 밖 별도 표기다. + from B09_Estimation.B09_Estimation_MaterialSheet import build_material_sheet + + result.material_sheet = build_material_sheet( + materials, + surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"), + ) + # ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13). from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis @@ -744,6 +754,7 @@ def bill_summary(result: BillResult) -> dict[str, Any]: "group_rows": sum(1 for r in result.rows if r.is_group), "excluded_rows": len(result.excluded), "material_rows": len(result.material_rows), + "material_sheet": result.material_sheet.as_dict() if result.material_sheet else None, "missing": result.missing, "body_total_krw": str(result.body_total_krw), "direct_material_krw": str(result.direct_material_krw), diff --git a/B09_Estimation/B09_Estimation_MaterialSheet.py b/B09_Estimation/B09_Estimation_MaterialSheet.py new file mode 100644 index 00000000..135b36ff --- /dev/null +++ b/B09_Estimation/B09_Estimation_MaterialSheet.py @@ -0,0 +1,180 @@ +"""B09 원가계산 — 자재대 표 (PLAN 8-7 「자재대·관급자재대(금액)는 B09」). + +**무엇인가** — B08 자재총괄이 낸 **수량·할증**에 **단가**를 붙여 금액을 내는 표다. +수량은 B08 것이 정본이고 여기서 다시 세지 않는다. + +**관급과 사급은 자리가 다르다** (PLAN 8-2 · 9-1) + - **사급** — 도급 재료비. 내역서 안에 들어간다. + - **관급** — **총원가 밖 별도 표기** + 조달수수료. ⑤ 공사원가계산서의 + 「관급자재대」와 같은 값이라 그쪽과 이어야 한다. + - **`unknown`** — 관급·사급이 안 갈린 것. **어느 쪽에도 안 넣는다** — 넣는 순간 + 총액이 틀리고, 어느 쪽으로 넣었는지 나중에 못 가린다. + +⚠ **할증은 여기서 한 번만** (PLAN 8-7 ㉠). B08 이 `total_amount` 에 이미 할증을 +반영해 보내면 그 값을 쓰고, 여기서 또 곱하지 않는다. `surcharge_status` 가 +`rate_unavailable` 이면 **할증 전 값**임을 표에 드러낸다. + +⚠ **단가가 없으면 금액을 만들지 않는다.** 사급 물가지가 미결(No.18)이라 지금은 +대부분이 그 자리다 — 0 으로 때우면 자재비가 통째로 사라진 채 총액이 그럴듯해진다. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_MaterialCatalog import ( + SUPPLY_CONTRACTOR, + SUPPLY_OWNER, + MaterialCatalog, + load_material_catalog, +) +from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at + +_ZERO = Decimal(0) + +#: 관급·사급이 안 갈린 값. B08 이 실제로 보낸다. +SUPPLY_UNKNOWN = "unknown" + +#: 할증 깃발 — B08 과 맞춘 세 갈래(2026-09-08). +SURCHARGE_APPLIED = "applied" +SURCHARGE_NOT_APPLIED = "not_applied" +SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" + + +@dataclass +class MaterialSheetRow: + """자재대 한 줄. 금액이 `None` 이면 **단가를 못 세운 것**이지 0 이 아니다.""" + + name: str + spec: str + unit: str + net_amount: Decimal + total_amount: Decimal + supply_type: str + unit_price_krw: Decimal | None = None + amount_krw: Decimal | None = None + surcharge_pct: Decimal | None = None + source_structure: tuple[str, ...] = () + note: str = "" + + def as_dict(self) -> dict[str, Any]: + def money(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + return { + "name": self.name, + "spec": self.spec, + "unit": self.unit, + "net_amount": str(self.net_amount), + "total_amount": str(self.total_amount), + "supply_type": self.supply_type, + "unit_price_krw": money(self.unit_price_krw), + "amount_krw": money(self.amount_krw), + "surcharge_pct": money(self.surcharge_pct), + "source_structure": list(self.source_structure), + "note": self.note, + } + + +@dataclass +class MaterialSheet: + """자재대 한 벌 — 사급·관급·미정 셋으로 갈린다.""" + + contractor_rows: list[MaterialSheetRow] = field(default_factory=list) + owner_rows: list[MaterialSheetRow] = field(default_factory=list) + unknown_rows: list[MaterialSheetRow] = field(default_factory=list) + #: 단가를 못 세운 줄 — **0 으로 안 때우고 이름째 남긴다.** + missing: list[dict[str, str]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + @property + def contractor_total_krw(self) -> Decimal: + """사급 자재비 합계 — 도급 재료비로 들어간다.""" + return sum((row.amount_krw or _ZERO for row in self.contractor_rows), _ZERO) + + @property + def owner_total_krw(self) -> Decimal: + """관급자재대 — **총원가 밖 별도 표기**. ⑤ 의 관급자재대와 같은 값이어야 한다.""" + return sum((row.amount_krw or _ZERO for row in self.owner_rows), _ZERO) + + def as_dict(self) -> dict[str, Any]: + return { + "contractor": [row.as_dict() for row in self.contractor_rows], + "owner": [row.as_dict() for row in self.owner_rows], + "unknown": [row.as_dict() for row in self.unknown_rows], + "contractor_total_krw": str(self.contractor_total_krw), + # 관급자재대는 **천원 올림** 자리다(단수처리 규칙). + "owner_total_krw": str( + round_at(self.owner_total_krw, OutputPlace.OWNER_MATERIAL_TOTAL) + ), + "missing": self.missing, + "notes": self.notes, + } + + +def build_material_sheet( + materials: list, + *, + surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE, + catalog: MaterialCatalog | None = None, +) -> MaterialSheet: + """자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.""" + book = catalog or load_material_catalog() + sheet = MaterialSheet() + + if surcharge_status == SURCHARGE_RATE_UNAVAILABLE: + sheet.notes.append( + "할증률이 아직 없어 **할증 전 수량**입니다 — 할증은 자재총괄에서 한 번만 " + "붙습니다 (PLAN 8-7 ㉠)." + ) + + for material in materials: + row = MaterialSheetRow( + name=getattr(material, "material_name", ""), + spec=getattr(material, "spec", ""), + unit=getattr(material, "unit", ""), + net_amount=getattr(material, "net_amount", _ZERO), + total_amount=getattr(material, "total_amount", _ZERO), + supply_type=getattr(material, "supply_type", SUPPLY_UNKNOWN), + surcharge_pct=getattr(material, "surcharge_pct", None), + source_structure=tuple(getattr(material, "source_structure", ()) or ()), + ) + + if row.supply_type == SUPPLY_UNKNOWN: + # ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다. + row.note = "관급·사급이 안 갈렸습니다 — 어느 쪽 합계에도 넣지 않습니다." + sheet.unknown_rows.append(row) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "공급 구분 미정(unknown)"} + ) + continue + + found = book.resolve(row.name, row.spec) + if found is None: + row.note = ( + "자재 단가가 없습니다 — 유료 물가지 미결(No.18). " + "6번 슬롯(적용 단가) 수동 입력 대기." + ) + sheet.missing.append( + {"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"} + ) + else: + row.unit_price_krw = found.price_krw + # 자재대 줄도 **내역서 본체와 같은 절사** 자리다. + row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW) + + if row.supply_type == SUPPLY_OWNER: + sheet.owner_rows.append(row) + elif row.supply_type == SUPPLY_CONTRACTOR: + sheet.contractor_rows.append(row) + else: + sheet.unknown_rows.append(row) + + if sheet.owner_rows: + sheet.notes.append( + "관급자재대는 **총원가 밖 별도 표기**입니다 — ⑤ 공사원가계산서의 " + "관급자재대와 같은 값이어야 합니다." + ) + return sheet diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 8f5e3fe1..7afc0166 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -552,7 +552,7 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["price_basis", "B09_Estimation_Tab_PriceBasis", true], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], - ["supply", "B09_Estimation_Tab_Supply", false], + ["supply", "B09_Estimation_Tab_Supply", true], ["base_data", "B09_Estimation_Tab_BaseData", false], ]; @@ -687,10 +687,31 @@ interface BillDto { blocked_kind?: string; }>; notes: string[]; + material_sheet: MaterialSheetDto | null; }; price_basis: { entries: PriceBasisEntryDto[] }; } +interface MaterialSheetRowDto { + name: string; + spec: string; + unit: string; + total_amount: string; + unit_price_krw: string | null; + amount_krw: string | null; + note: string; +} + +interface MaterialSheetDto { + contractor: MaterialSheetRowDto[]; + owner: MaterialSheetRowDto[]; + unknown: MaterialSheetRowDto[]; + contractor_total_krw: string; + owner_total_krw: string; + missing: Array<{ name: string; reason: string }>; + notes: string[]; +} + /** * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. * @@ -987,6 +1008,63 @@ export async function renderB09Estimation(root: HTMLElement): Promise { body.append(split); }; + /** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */ + const drawMaterialTab = (): void => { + const sheet = bill?.summary.material_sheet ?? null; + if (!sheet) { + const empty = document.createElement("div"); + empty.className = "b09-empty"; + empty.textContent = L("B09_Estimation_Mat_Empty"); + body.append(empty); + return; + } + + for (const [labelKey, rows, total] of [ + ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw], + ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw], + ["B09_Estimation_Mat_Unknown", sheet.unknown, null], + ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) { + const head = document.createElement("div"); + head.className = "b09-hint"; + head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); + body.append(head); + if (rows.length === 0) continue; + + const table = document.createElement("table"); + table.className = "b09-sheet"; + table.innerHTML = + "자재규격단위수량" + + "단가금액비고"; + const tbody = document.createElement("tbody"); + for (const row of rows) { + const tr = document.createElement("tr"); + for (const text of [ + row.name, + row.spec, + row.unit, + formatQuantity(row.total_amount), + row.unit_price_krw ?? "", + row.amount_krw ?? "", + row.note, + ]) { + const td = document.createElement("td"); + td.textContent = text; + tr.append(td); + } + tbody.append(tr); + } + table.append(tbody); + body.append(table); + } + + for (const note of sheet.notes) { + const line = document.createElement("div"); + line.className = "b09-hint"; + line.textContent = note.replace(/\*\*/g, ""); + body.append(line); + } + }; + const drawBody = (): void => { body.replaceChildren(); if (activeTab === "unit_price") { @@ -1001,6 +1079,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawPriceBasisTab(); return; } + if (activeTab === "supply") { + drawMaterialTab(); + return; + } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index e8426f88..63743d55 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -677,6 +677,19 @@ export const ui_locales_b2 = { ], B09_Estimation_PB_Pick: ["왼쪽에서 산출서를 고르세요.", "Pick a sheet on the left."], B09_Estimation_PB_Ref: ["참조", "Refers to"], + B09_Estimation_Mat_Contractor: ["사급 자재 (도급 재료비)", "Contractor-supplied (in the bill)"], + B09_Estimation_Mat_Owner: [ + "관급 자재 — 총원가 밖 별도 표기", + "Owner-supplied — listed outside the total cost", + ], + B09_Estimation_Mat_Unknown: [ + "관급·사급이 안 갈린 것 — 어느 합계에도 안 넣습니다", + "Supply type undecided — excluded from both totals", + ], + B09_Estimation_Mat_Empty: [ + "설계내역서를 먼저 불러오면 자재대가 섭니다.", + "Load the bill first and the material sheet appears.", + ], B09_Estimation_Boq_Precision: [ "수량 표시는 소수 2자리, 계산은 전정밀 — 표시값끼리 곱하면 끝자리가 다릅니다.", "Quantities are shown to 2 decimals but computed at full precision — multiplying the shown values gives a slightly different last digit.", From 47fa317b2602f9c2d13ae5b957301d919f631b47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 05:15:35 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(B09):=20=EA=B4=80=EA=B8=89=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=A5=BC=20=E3=80=8C=EC=82=AC=EA=B8=89=E3=80=8D?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=20=EC=A0=81=EB=8D=98=20=EB=AC=B8=EA=B5=AC=20?= =?UTF-8?q?=EA=B0=88=EB=9D=BC=20=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메인 창이 인계를 실제로 먹여 잡음 — 물구멍·야면석이 `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었음. `unknown` 만 가르고 나머지를 한 줄로 떨어뜨린 탓 - 관급 — 「관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. 관급자재대(총원가 밖 별도 표기)로 갑니다」 - 사급 — 종전 문구(물가지 미결 No.18, 6번 슬롯 수동 입력 대기) - `missing` 에 `supply_type` 을 실어 화면이 갈래로 묶을 수 있게 함 - 갈래마다 **가는 자리도 원천도 다름** — 관급은 총원가 밖·나라장터, 사급은 도급 재료비·물가지 검증: pytest 206 통과(신규 1) Co-Authored-By: Claude Opus 5 (1M context) --- .../B09_Estimation_BillOfQuantities.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 9e798367..d8410b1f 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -43,6 +43,9 @@ _ZERO = Decimal(0) #: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다. SUPPLY_UNKNOWN = "unknown" +#: 관급 — 총원가 밖 별도 표기라 사급과 **가는 자리가 다르다**(PLAN 8-2). +SUPPLY_OWNER = "owner_supplied" + #: 막힘 갈래를 사람 말로. **할 일이 다르므로 화면에서 갈라 보인다.** _BLOCKED_LABELS = { "input_missing": "입력이 필요합니다", @@ -733,14 +736,27 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow: } ) return row - # 사급 자재 단가는 아직 원천이 없다(미결 No.18) — 여기서도 지어내지 않는다. - row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + # ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이 + # `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도 + # 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지). + if material.supply_type == SUPPLY_OWNER: + row.note = ( + row.note + or "관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. " + "관급자재대(총원가 밖 별도 표기)로 갑니다." + ) + reason = "관급 자재 단가 없음" + else: + row.note = row.note or "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기." + reason = "사급 자재 단가 없음(미결 No.18)" + result.missing.append( { "name": material.display_name, "unit": material.unit, "quantity": str(material.total_amount), - "reason": "자재 단가 없음(미결 No.18)", + "reason": reason, + "supply_type": material.supply_type, } ) return row