feat(b09): 일위대가 호표 성분 소계 절사 · 내역 줄 성분별 절사 — 골든셋 네 벌 다 섬
- 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
)
|
||||
|
||||
@@ -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 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 ㎥",
|
||||
|
||||
Reference in New Issue
Block a user