From fc649316c678e888dee77d436e98e952a6898ff7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 00:53:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=ED=98=B8=ED=91=9C=20=EC=84=B1=EB=B6=84=20=EC=86=8C?= =?UTF-8?q?=EA=B3=84=20=EC=A0=88=EC=82=AC=20=C2=B7=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=A4=84=20=EC=84=B1=EB=B6=84=EB=B3=84=20=EC=A0=88=EC=82=AC=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A8=EB=93=A0=EC=85=8B=20=EB=84=A4=20=EB=B2=8C?= =?UTF-8?q?=20=EB=8B=A4=20=EC=84=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TRUNCATED_KINDS 에 UNIT_PRICE(B) 더함 · 비율 줄(공구손료·제잡비·품 할증)도 0.1원 절사 - 내역 줄 bill_line — 성분마다 절사(수량 × 성분 단가), 합계 = 셋의 합 · 직접비 밑수도 자른 성분 - 묶음 줄·구조물도 일위대가 표(하위 양식 포함)도 같은 규칙 — 성분 소계 원 미만 절사 - 골든셋 ① 일위대가표 성분 소계 477/510(93.5%) · 안 맞는 19 호표 = 영월 반올림 원본 17 · 영덕 적용률 줄 서식 2 - 골든셋 ① 설계내역서 줄 321 전수 일치 - 검증 프로젝트 내역 본체 122,870,975 → 122,848,989 · 전체 시험 1614 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- .../B08_Quantity_Engine_StructurePriceLink.py | 6 +- .../B08_Quantity_Engine_StructureUnitPrice.py | 22 ++-- .../B09_Estimation_BillOfQuantities.py | 1 + .../B09_Estimation_BillOfQuantities_Rows.py | 36 ++++-- B09_Estimation/B09_Estimation_PriceBook.py | 11 +- B09_Estimation/B09_Estimation_UnitPrice.py | 3 +- resources/tester/test_b09_golden_stmate.py | 117 +++++++++++++++++- resources/tester/test_b09_work_items_ax.py | 4 +- 8 files changed, 171 insertions(+), 29 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py b/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py index af6fc4f6..976bae3d 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py +++ b/B08_Quantity/B08_Quantity_Engine_StructurePriceLink.py @@ -132,9 +132,9 @@ def price_sheets( ) -> dict[str, dict[str, Any]]: """B09 내역이 쓸 호표 금액 `{B-AX-ST-…#키: {money, total, blocked, unconfirmed, reasons}}`. - ⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사한 합) · `total` 은 계금(1원 절사) — - 명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도 화면(192,429)과 - 내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측). + ⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사해 더한 뒤 성분마다 1원 절사) · + `total` 은 그 합 — 명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도 + 화면(192,429)과 내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측). """ from decimal import Decimal diff --git a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py index 562d7200..ef8064e6 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureUnitPrice.py @@ -2,8 +2,8 @@ ⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값. ⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」. -⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2). - 하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름. +⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 성분 소계 1원 미만 버림(명세 7장). + 하위 일위대가를 윗 표에 넣을 때도 **자른 성분 소계**를 씀 — B09 `resolve` 의 B 호표와 같음. ⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어 일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장). 깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함. @@ -166,7 +166,6 @@ def _assemble( values = (sheet.get("formula_sheet") or {}).get("vars") or {} rows: list[dict[str, Any]] = [] sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)} - exact = Money3() blocked = 0 unconfirmed = 0 for row in spec.get("rows") or []: @@ -244,7 +243,6 @@ def _assemble( continue money = priced.money - exact = exact + money.scaled(quantity) cells = { "material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW), "labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW), @@ -269,15 +267,21 @@ def _assemble( "name": template.get("name") or "", "unit": sheet.get("billing_unit") or "", "rows": rows, - **{key: float(value) for key, value in sums.items()}, - # 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음. - "total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)), + # 성분 소계 — 성분마다 1원 미만 버림(명세 7장 · B09 일위대가 호표와 같은 규칙). + **{ + key: float(round_at(value, OutputPlace.UNIT_PRICE_TOTAL)) for key, value in sums.items() + }, + # 계금 = 자른 성분 소계의 합. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음. + "total": float( + sum(round_at(value, OutputPlace.UNIT_PRICE_TOTAL) for value in sums.values()) + ), "blocked": blocked, "complete": blocked == 0, #: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지. "unconfirmed": unconfirmed, } - return table, exact + # 윗 표·내역이 쓸 단위당 3분할 = **자른 성분 소계**(하위 호표 합계를 그대로 부름 — 명세 7장). + return table, Money3(*(Decimal(str(table[key])) for key in _MONEY_KEYS)) def unit_price_table( @@ -307,7 +311,7 @@ def unit_price_money( library: Iterable[dict[str, Any]] = (), manual: dict[str, dict[str, Any]] | None = None, ) -> tuple[dict[str, Any], Any] | None: - """`unit_price_table` 과 같되 **안 자른 단위당 3분할**도 — B09 내역 줄이 이 값에 수량을 곱함.""" + """`unit_price_table` 과 같되 **단위당 3분할**(자른 성분 소계)도 — B09 내역 줄이 이 값에 수량을 곱함.""" ctx = _Context( book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {} ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities.py b/B09_Estimation/B09_Estimation_BillOfQuantities.py index 6a6a70d9..ff463c61 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities.py @@ -370,6 +370,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402 _leaf_row, _material_row, _structure_price_row, + bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름) ) diff --git a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py index 11d00e31..50394419 100644 --- a/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py +++ b/B09_Estimation/B09_Estimation_BillOfQuantities_Rows.py @@ -9,6 +9,8 @@ from __future__ import annotations +from decimal import Decimal + from B09_Estimation.B09_Estimation_BillOfQuantities import ( SUPPLY_OWNER, SUPPLY_UNKNOWN, @@ -20,10 +22,24 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import ( _MasterNode, _decimal, ) +from B09_Estimation.B09_Estimation_PriceBook import Money3 from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code +def bill_line(unit: Money3, quantity) -> Money3: + """내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장). + + 근거 STmate 16번 행 금액 1,515건(505줄 × 성분 셋) · 착공내역서 178건 전수 절사. + ⚠ 합을 한 번에 자르면 성분 합과 1~2원 갈림 — 원가계산서 직접비 밑수도 이 자른 성분의 합. + """ + return Money3( + material=round_at(unit.material * quantity, OutputPlace.BOQ_ROW), + labor=round_at(unit.labor * quantity, OutputPlace.BOQ_ROW), + expense=round_at(unit.expense * quantity, OutputPlace.BOQ_ROW), + ) + + def _composite_row( item_no: str, item: HandoffWorkItem, @@ -54,7 +70,8 @@ def _composite_row( if not code or amount is None or f"B-{code}" not in unit_prices.book.titles: missing_parts.append(code or str(part.get("name") or "이름 없음")) continue - scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount) + # 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장). + scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1")) money = scaled if money is None else money + scaled reasons: list[str] = [] @@ -85,9 +102,10 @@ def _composite_row( ) return row - line = money.scaled(item.quantity) + money = money.floored(Decimal(1)) + line = bill_line(money, item.quantity) row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW) - row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + row.amount_krw = line.total row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense @@ -141,9 +159,9 @@ def _structure_price_row( return row # 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장). - line = entry["money"].scaled(item.quantity) + line = bill_line(entry["money"], item.quantity) row.unit_price_krw = entry["total"] - row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) + row.amount_krw = line.total row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense @@ -443,11 +461,11 @@ def _leaf_row( row.price_code = price_code unit_money = unit_prices.book.resolve(price_code) - line = unit_money.scaled(item.quantity) + line = bill_line(unit_money, item.quantity) row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW) - # 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상. - row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW) - # 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다. + # 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상. + row.amount_krw = line.total + # 3분할도 자른 값 — ⑤ 직접비 밑수가 곧 성분별로 자른 줄 금액의 합(STmate 16번 원 단위 일치). row.material_krw = line.material row.labor_krw = line.labor row.expense_krw = line.expense diff --git a/B09_Estimation/B09_Estimation_PriceBook.py b/B09_Estimation/B09_Estimation_PriceBook.py index 5b41c6dd..cd245c59 100644 --- a/B09_Estimation/B09_Estimation_PriceBook.py +++ b/B09_Estimation/B09_Estimation_PriceBook.py @@ -61,8 +61,10 @@ CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHIN #: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사 #: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수). #: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545). -#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **아래층(D)부터 맞춘 뒤** 붙임(PLAN 6장 판정). -TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS}) +#: B(일위대가)도 같음 — 골든셋 실무 성분 소계 477/510(93.5%, 17번 원문 94.2%). 안 맞는 것은 반올림으로 +#: 뽑은 원본 한 건·적용률 줄 서식 — **새 반올림 규칙을 넣지 말 것**(PLAN 6장 판정). +#: ⚠ 비율 줄(공구손료·제잡비·품 할증)도 줄 금액이라 0.1원 미만 절사. +TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS, PriceKind.UNIT_PRICE}) _TENTH = Decimal("0.1") _WON = Decimal(1) @@ -290,6 +292,8 @@ class PriceBook: # 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다. # ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다. share = direct_labor * row.percent_of_labor / Decimal(100) + if truncate: + share = share.quantize(_TENTH, rounding=ROUND_FLOOR) if row.percent_of_labor_target == "labor": # 품 할인·할증(1-4) — **품이 늘어난 것**이라 노무비로 들어가고, 뒤에 오는 # 제잡비의 밑수에도 든다. @@ -317,7 +321,8 @@ class PriceBook: child = self.resolve(row.ref_code, (*_seen, code)) if row.percent_of_parent is not None: # 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등). - total = total + total.scaled(row.percent_of_parent / Decimal(100)) + share = total.scaled(row.percent_of_parent / Decimal(100)) + total = total + (share.floored(_TENTH) if truncate else share) continue scaled = child.scaled(row.quantity) if truncate: diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index e0459e0d..fe80c425 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -1238,7 +1238,8 @@ def direct_cost_from_quantities( result.missing.append(raw_code) continue unit_money = book.resolve(code) - line = unit_money.scaled(Decimal(str(quantity))) + # 내역 줄과 같은 자르기 — 성분마다 1원 미만 절사(`BillOfQuantities_Rows.bill_line`). + line = unit_money.scaled(Decimal(str(quantity))).floored(Decimal(1)) result.material += line.material result.labor += line.labor result.expense += line.expense diff --git a/resources/tester/test_b09_golden_stmate.py b/resources/tester/test_b09_golden_stmate.py index 2698da2f..400bfc3f 100644 --- a/resources/tester/test_b09_golden_stmate.py +++ b/resources/tester/test_b09_golden_stmate.py @@ -6,7 +6,7 @@ ② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅ ④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅ ③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅ - ① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막) + ① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅ ⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님). ⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀. @@ -39,7 +39,7 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서" def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: """실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`.""" openpyxl = pytest.importorskip("openpyxl") - wanted = ("환율및기초자료", "중기사용료", "단가산출근거") + wanted = ("환율및기초자료", "중기사용료", "단가산출근거", "일위대가표", "설계내역서") found = [] for path in sorted(PRACTICE.rglob("*.xlsx")): if path.name.startswith("~$"): @@ -51,7 +51,7 @@ def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]: except Exception: # 깨진 사본 — 기준점이 아님 continue sheets = { - name: list(book[name].iter_rows(max_col=12, values_only=True)) + name: list(book[name].iter_rows(max_col=13, values_only=True)) for name in wanted if name in book.sheetnames } @@ -280,3 +280,114 @@ def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None: book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors))) # 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림. assert book.resolve("D").expense == Decimal(3545) + + +_COMPONENT_KINDS = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE) + + +def _unit_price_blocks() -> list[tuple[str, str, tuple, list[tuple]]]: + """`일위대가표` 시트의 호표 — `(원본, 호표, 합계 줄, 구성 줄들)`. + + 칸: 명칭 · 규격 · 수량 · 단위 · 합계(단가·금액) · 노무 · 재료 · 경비. 끝 줄은 「합계」·「계」· + 「합계(경비로적용)」 — 뒤따르는 「계약단가」(낙찰률 곱)는 절사 규칙 대조 밖. + """ + found = [] + for name, sheets in _workbooks(): + rows: list[tuple] | None = None + title = "" + for row in sheets.get("일위대가표", []): + head = str(row[0] or "").replace(" ", "") + if head.startswith("제") and head.endswith("호표"): + rows, title = [], head + elif rows is None: + continue + elif head in ("합계", "계") or head.startswith("합계("): + found.append((name, title, row, rows)) + rows = None + elif _num(row[2]) is not None and row[3]: + rows.append(row) + return found + + +def _assemble_unit_price(rows: list[tuple]) -> Money3: + """원본 구성 줄(수량 · 성분 단가)을 **우리 B 층**에 올려 풂 — 줄 0.1원 · 성분 소계 원 미만(엔진).""" + book = PriceBook() + book.add_title(PriceTitle("B", PriceKind.UNIT_PRICE, "일위대가")) + for index, row in enumerate(rows): + quantity = _num(row[2]) + if str(row[3]).strip() == "%": # 「노무비의 3 %」 — 단가 칸이 밑수, 수량 칸이 백분율 수 + quantity /= 100 + for kind, part in zip(_COMPONENT_KINDS, (_num(row[6]), _num(row[8]), _num(row[10]))): + if part: + code = f"R{index}{kind.name}" + book.add_title(PriceTitle(code, kind, str(row[0]), slots=_slots(part))) + book.add_detail(PriceDetail("B", code, quantity)) + return book.resolve("B") if book.details else Money3() + + +def test_일위대가_실무_원본_호표_성분_소계_절사() -> None: + """① 줄 = 수량 × 성분 단가 → 0.1원 미만 절사 · 성분 소계 원 미만 절사(명세 7장). + + ⚠ **100%를 좇지 않음**(브레인 판정) — 17번 원문이 「345건 중 94.2%, 나머지는 호표 머리·부분계 + 서식이 달라 자동 구획이 어긋난 것」이라 적음. 안 맞는 호표는 **서식 탓·반올림 탓**을 갈라 둠. + """ + blocks = _unit_price_blocks() + if not blocks: + pytest.skip("실무 원본 XLSX 가 없음") + misses, components, matched = [], 0, 0 + for name, title, total, rows in blocks: + got = _assemble_unit_price(rows) + want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11])) + if not want[1] and not want[2] and want[0] == want[3]: + got = Money3(expense=got.total) # 합계(경비로적용) + pairs = list(zip(want[1:], (got.labor, got.material, got.expense))) + components += len(pairs) + matched += sum(w == g for w, g in pairs) + if (got.total, got.labor, got.material, got.expense) != want: + misses.append( + (name, title, want, (got.total, got.labor, got.material, got.expense), rows) + ) + assert len(blocks) >= 150, len(blocks) # 6건 170 호표 · 성분 소계 510 + # 성분 소계 477/510 = 93.5%(17번 원문 94.2% 언저리). 안 맞는 19 호표는 둘로 갈림 — + # 반올림 탓 17: 영월 2024 한 건이 일위대가표를 **절사가 아닌 반올림 자리**로 뽑음(같은 원본의 + # 중기사용료·단가산출근거는 절사로 전수 일치 — 그 원본의 단수 설정, 엔진 규칙 아님) + # 서식 탓 2: 영덕 2025 「켜쌓기적용 90 %」 줄이 앞 줄 합을 밑수로 받아 **앞 줄을 대신함** + assert matched >= components * 0.93, (matched, components) + formatted = [m for m in misses if "영월" not in m[0]] + assert len(formatted) <= 2, [m[:4] for m in formatted] + assert all(any(str(r[3]).strip() == "%" for r in m[4]) for m in formatted) + # 봉화 2024 제1호표 깬잡석찰쌓기 — 노무 20,714.8 + 6,621.8 + 16,710 = 44,046 · 합계 58,889. + first = next(b for b in blocks if "현동" in b[0] and b[1] == "제1호표") + assert _assemble_unit_price(first[3]).total == Decimal(58889) + + +def _bill_rows() -> list[tuple[str, tuple]]: + """`설계내역서` 말단 줄 — 칸: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비(단가·금액).""" + return [ + (name, row) + for name, sheets in _workbooks() + for row in sheets.get("설계내역서", []) + if _num(row[3]) is not None and row[4] and any(_num(row[i]) for i in (7, 9, 11)) + ] + + +def test_내역_줄_금액은_성분마다_절사한_합() -> None: + """① 내역 줄 — 성분마다 `절사(수량 × 성분 단가)` · 합계 = 셋의 합(16번 1,515건 · 착공 178건 100%).""" + from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line + + rows = _bill_rows() + if not rows: + pytest.skip("실무 원본 XLSX 가 없음") + misses = [] + for name, row in rows: + unit = Money3(material=_num(row[9]), labor=_num(row[7]), expense=_num(row[11])) + quantity = _num(row[3]) + if str(row[4]).strip() == "%": # 조달수수료 「재료비 × 0.54 %」 — 단가 칸이 밑수 + quantity /= 100 + line = bill_line(unit, quantity) + got = (line.total, line.labor, line.material, line.expense) + want = (_num(row[6]), _num(row[8]), _num(row[10]), _num(row[12])) + if got != want: + misses.append((name, row[1], want, got)) + assert len(rows) >= 300, len(rows) + assert not misses, (len(misses), misses[:5]) diff --git a/resources/tester/test_b09_work_items_ax.py b/resources/tester/test_b09_work_items_ax.py index 35ed26a2..a7b54390 100644 --- a/resources/tester/test_b09_work_items_ax.py +++ b/resources/tester/test_b09_work_items_ax.py @@ -18,7 +18,9 @@ def test_모르타르_배합은_보통인부_066인이고_자재는_드러내기 assert (title.name, title.spec, title.unit) == ("모르타르 배합", "1:3", "㎥") money = build.book.resolve(f"B-{MORTAR_MIX}") wage = build.book.resolve("1002").labor # 보통인부 - assert money.labor == wage * Decimal("0.66") and money.material == 0 + # 일위대가 호표 성분 소계는 원 미만 절사(명세 7장 · PLAN 6장 ①). + assert money.labor == (wage * Decimal("0.66")).to_integral_value("ROUND_FLOOR") + assert money.material == 0 assert [label.split(" —")[0] for label in build.unattached[MORTAR_MIX]] == [ "시멘트 510 kg", "모래 1.10 ㎥",