Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-14 00:55:31 +09:00
8 changed files with 171 additions and 29 deletions
@@ -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
+8 -3
View File
@@ -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:
+2 -1
View File
@@ -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
+114 -3
View File
@@ -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])
+3 -1
View File
@@ -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 ㎥",