- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음 (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임 - 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144) - 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함 - 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에 - 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot) - 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감 - 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임) - 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
442 lines
21 KiB
Python
442 lines
21 KiB
Python
"""STmate 골든셋 재현 — 실무 내역 원본(XLSX)을 **우리 엔진**으로 되풀어 대조(PLAN 6장·명세 8장).
|
||
|
||
기준점 — `resources/knowledge/original/실무문서/` 의 실무 6건(STmate 출력 XLSX).
|
||
브레인 완료 판정 자리 — 6장 사슬 단계를 하나 세울 때마다 여기 한 벌씩 더하고 **단계마다 돌림**.
|
||
|
||
② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅
|
||
④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅
|
||
③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅
|
||
① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅
|
||
|
||
⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님).
|
||
⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pathlib
|
||
import warnings
|
||
from decimal import Decimal
|
||
from functools import lru_cache
|
||
|
||
import pytest
|
||
|
||
from B09_Estimation.B09_Estimation_MachineCost import hourly_operator_wage
|
||
from B09_Estimation.B09_Estimation_PriceBook import (
|
||
Money3,
|
||
PriceBook,
|
||
PriceDetail,
|
||
PriceKind,
|
||
PriceTitle,
|
||
)
|
||
from B09_Estimation.B09_Estimation_UnitPrice import _misc_row, _slots
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||
PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
|
||
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
|
||
openpyxl = pytest.importorskip("openpyxl")
|
||
wanted = (
|
||
"환율및기초자료",
|
||
"중기사용료",
|
||
"단가산출근거",
|
||
"일위대가표",
|
||
"설계내역서",
|
||
"재료비수량금액집계표",
|
||
"노무비수량금액집계표",
|
||
"경비수량금액집계표",
|
||
"중기시간금액집계표",
|
||
"중기목록표",
|
||
)
|
||
found = []
|
||
for path in sorted(PRACTICE.rglob("*.xlsx")):
|
||
if path.name.startswith("~$"):
|
||
continue
|
||
with warnings.catch_warnings():
|
||
warnings.simplefilter("ignore")
|
||
try:
|
||
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
|
||
except Exception: # 깨진 사본 — 기준점이 아님
|
||
continue
|
||
sheets = {
|
||
name: list(book[name].iter_rows(max_col=13, values_only=True))
|
||
for name in wanted
|
||
if name in book.sheetnames
|
||
}
|
||
book.close()
|
||
if sheets:
|
||
found.append((str(path.relative_to(PRACTICE)), sheets))
|
||
return tuple(found)
|
||
|
||
|
||
def _decimal_places(value: Decimal) -> int:
|
||
return max(0, -value.normalize().as_tuple().exponent)
|
||
|
||
|
||
def _wage_rows() -> list[tuple[str, str, Decimal, str, Decimal]]:
|
||
rows = []
|
||
for name, sheets in _workbooks():
|
||
for row in sheets.get("환율및기초자료", []):
|
||
if len(row) < 7 or not isinstance(row[3], (int, float)) or not isinstance(row[4], str):
|
||
continue
|
||
if row[2] and isinstance(row[6], (int, float)):
|
||
formula = row[4].replace("*", "", 1).replace("=", "").strip()
|
||
rows.append(
|
||
(name, str(row[2]), Decimal(str(row[3])), formula, Decimal(str(row[6])))
|
||
)
|
||
return rows
|
||
|
||
|
||
def test_노임_시간당_환산_실무_원본_전수_재현() -> None:
|
||
"""② 일당 × `1/8*16/12*25/20` 좌→우 순차 + 절사 — 실무 원본의 시간당 칸과 **전부** 같음.
|
||
|
||
절사 자리는 원본이 보인 대로(다섯 건 원 · 2025 울진 소광 0.1원) — 설정값이라 칸에서 읽음.
|
||
"""
|
||
rows = _wage_rows()
|
||
if not rows:
|
||
pytest.skip("실무 원본 XLSX 가 없음")
|
||
assert len(rows) >= 18 # 6건 × 운전사 3직종
|
||
misses = [
|
||
(name, job, daily, expected, got)
|
||
for name, job, daily, formula, expected in rows
|
||
if (got := hourly_operator_wage(daily, formula, digits=_decimal_places(expected)))
|
||
!= expected
|
||
]
|
||
assert not misses, misses
|
||
|
||
|
||
def _num(value: object) -> Decimal | None:
|
||
return Decimal(str(value)) if isinstance(value, (int, float)) else None
|
||
|
||
|
||
def _machine_sheets() -> list[tuple[str, str, list[tuple], tuple]]:
|
||
"""`중기사용료` 시트의 호표 — `(원본, 호표 이름, 구성 줄들, 합계 줄)`."""
|
||
found = []
|
||
for name, sheets in _workbooks():
|
||
block: list[tuple] | None = None
|
||
title = ""
|
||
for row in sheets.get("중기사용료", []):
|
||
head = str(row[0] or "").replace(" ", "")
|
||
if head.startswith("제") and head.endswith("호표"):
|
||
block, title = [], ""
|
||
continue
|
||
if block is None:
|
||
continue
|
||
if head == "합계":
|
||
found.append((name, title, block, row))
|
||
block = None
|
||
elif row[2] is None and row[3] and not title:
|
||
title = f"{row[0]} {row[1] or ''}".strip()
|
||
elif _num(row[2]) is not None:
|
||
block.append(row)
|
||
return found
|
||
|
||
|
||
def _assemble(rows: list[tuple]) -> Money3 | None:
|
||
"""원본 호표 줄을 **우리 단가표 층**(S·L·M·잡품 비율 줄 → X)에 올려 풂. 모르는 줄이면 `None`."""
|
||
book = PriceBook()
|
||
book.add_title(PriceTitle("X", PriceKind.MACHINE_HOURLY, "호표"))
|
||
for index, row in enumerate(rows):
|
||
quantity, price, unit = _num(row[2]), _num(row[4]), str(row[3] or "").strip()
|
||
code = f"R{index}"
|
||
if unit == "%":
|
||
# 이름은 원본마다 다름(잡품·잡재료·잡유·잡재료비) — 「주연료의 %」 가산 행 모양.
|
||
book.add_detail(_misc_row("X", quantity))
|
||
continue
|
||
kind = {"천원": PriceKind.MACHINE_BASE, "인": PriceKind.LABOR}.get(unit, PriceKind.MATERIAL)
|
||
if price is None:
|
||
return None
|
||
book.add_title(PriceTitle(code, kind, str(row[0]), unit=unit, slots=_slots(price)))
|
||
book.add_detail(PriceDetail("X", code, quantity))
|
||
return book.resolve("X")
|
||
|
||
|
||
def test_중기_시간당_사용료_실무_원본_호표_전수_재현() -> None:
|
||
"""④ 손료·운전원·주연료·**잡품(주연료비 × 율 가산 행)** — 줄 0.1원 · 성분 소계 원 미만 절사.
|
||
|
||
회귀 기준 — 봉화 2024 제2호표 굴삭기 0.7㎥: 23,128 + 55,700 + 18,015 = **96,843**(명세 7장).
|
||
"""
|
||
sheets = _machine_sheets()
|
||
if not sheets:
|
||
pytest.skip("실무 원본 XLSX 가 없음")
|
||
checked = 0
|
||
misses = []
|
||
for name, title, rows, total in sheets:
|
||
got = _assemble(rows)
|
||
if got is None:
|
||
continue
|
||
checked += 1
|
||
want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11]))
|
||
if (got.total, got.labor, got.material, got.expense) != want:
|
||
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
|
||
assert checked >= 50, checked
|
||
assert not misses, misses[:5]
|
||
bonghwa = next(t for t in sheets if "현동" in t[0] and "0.7" in t[1])
|
||
assert _assemble(bonghwa[2]).total == Decimal(96843)
|
||
|
||
|
||
def _basis_blocks() -> list[tuple[str, str, tuple, list[tuple]]]:
|
||
"""`단가산출근거` 시트의 호표 — `(원본, 호표, 머리 줄, Q 줄들)`. 머리 = 합계·노무·재료·경비.
|
||
|
||
`소계(제외금액)`(틀 `(-=)`) 위 줄은 머리에 안 들어감 — Q 를 끌어내려고 보인 인력 품 따위.
|
||
"""
|
||
found = []
|
||
for name, sheets in _workbooks():
|
||
head: tuple | None = None
|
||
rows: list[tuple] = []
|
||
kept = 0 # 마지막 소계까지 든 줄 수
|
||
for row in [*sheets.get("단가산출근거", []), ("총계", None)]:
|
||
label = str(row[1] or "").replace(" ", "")
|
||
first = str(row[0] or "").replace(" ", "")
|
||
if (label.startswith("제") and label.endswith("호표")) or first.startswith("총"):
|
||
if head is not None and rows:
|
||
found.append((name, str(head[1]).replace(" ", ""), head, rows))
|
||
head, rows, kept = (row if label.endswith("호표") else None), [], 0
|
||
continue
|
||
if label.startswith("소계") or label.startswith("계("):
|
||
template = str(row[6])
|
||
if "(-==)" in template: # 계(제외금액) — 그때까지 든 줄 전부
|
||
rows, kept = [], 0
|
||
elif "(-=)" in template: # 소계(제외금액) — 마지막 소계 뒤 줄
|
||
rows = rows[:kept]
|
||
else:
|
||
kept = len(rows)
|
||
continue
|
||
if (
|
||
head is not None
|
||
and first.startswith("계") # 「계」 · 「계(경비로적용)」
|
||
and _num(row[2]) is not None
|
||
and _num(row[7]) is None # 「계약단가」·「계 x 낙찰율」 은 율 칸이 참
|
||
):
|
||
# 머리가 낙찰률을 곱한 계약단가인 원본 — 절사 규칙 대조는 그 앞 「계」 줄로.
|
||
head = (head[0], head[1], *row[2:])
|
||
continue
|
||
if (
|
||
head is not None
|
||
and len(row) > 8
|
||
and _num(row[7]) is not None
|
||
and _num(row[8]) is not None
|
||
):
|
||
rows.append(row)
|
||
return found
|
||
|
||
|
||
def _assemble_basis(rows: list[tuple]) -> Money3:
|
||
"""원본 Q 줄(QTY · 성분 단가 J·K·L)을 **우리 D 층**에 올려 풂 — 줄 0.1원 · 머리 원 미만(엔진 규칙).
|
||
|
||
성분 단가 칸이 둘 이상 찬 줄은 다른 호표 참조(「산근 N호표」) — 성분마다 잎 하나씩.
|
||
"""
|
||
kinds = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE)
|
||
book = PriceBook()
|
||
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
|
||
for index, row in enumerate(rows):
|
||
quantity, price, shown = _num(row[7]), _num(row[8]), _num(row[2]) or Decimal(0)
|
||
if abs(quantity * price / 100 - shown) < abs(quantity * price - shown):
|
||
quantity /= 100 # 공구손료 「노무비 × 2 %」 — 칸 QTY 가 백분율 수인 줄
|
||
parts = [(k, _num(p)) for k, p in zip(kinds, (*row[9:12], None, None, None))]
|
||
parts = [(k, p) for k, p in parts if p]
|
||
if not parts: # 성분 단가 칸이 빈 원본 — 줄 금액이 든 성분 칸으로 가름
|
||
kind = kinds[next((i for i in range(3) if _num(row[3 + i])), 2)]
|
||
parts = [(kind, price)]
|
||
for kind, part in parts:
|
||
code = f"R{index}{kind.name}"
|
||
book.add_title(PriceTitle(code, kind, str(row[1]), slots=_slots(part)))
|
||
book.add_detail(PriceDetail("D", code, quantity))
|
||
return book.resolve("D")
|
||
|
||
|
||
def test_단가산출_Q_식_실무_원본_호표_전수_재현() -> None:
|
||
"""③ 줄 = 중기 성분 × 1/Q(Q 소수 2자리 확정) → 0.1원 미만 절사 · 머리 성분 = 원 미만 절사.
|
||
|
||
회귀 기준 — 봉화 2024 제2호표 굴삭기 0.2㎥ Q 15.71: 노무 55,700 ÷ Q = **3,545.5** → 머리 3,545(명세 7장).
|
||
"""
|
||
blocks = _basis_blocks()
|
||
if not blocks:
|
||
pytest.skip("실무 원본 XLSX 가 없음")
|
||
misses = []
|
||
for name, title, head, rows in blocks:
|
||
got = _assemble_basis(rows)
|
||
want = (_num(head[2]), _num(head[3]), _num(head[4]), _num(head[5]))
|
||
if not want[1] and not want[2] and want[0] == want[3]:
|
||
# 경비로 넘기는 호표(운반·소운반 따위) — 성분 절사 합을 경비 한 칸에 모음.
|
||
got = Money3(expense=got.total)
|
||
if (got.total, got.labor, got.material, got.expense) != want:
|
||
misses.append((name, title, want, (got.total, got.labor, got.material, got.expense)))
|
||
assert len(blocks) >= 200, len(blocks) # 6건 208 호표
|
||
assert not misses, (len(misses), misses[:5])
|
||
second = next(b for b in blocks if "현동" in b[0] and b[1] == "제2호표")
|
||
assert Decimal("3545.5") in [_num(r[3]) for r in second[3]]
|
||
assert _assemble_basis(second[3]).labor == Decimal(3545)
|
||
|
||
|
||
def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None:
|
||
"""③ 엔진 — 18번 §2.2 예: q 0.2 · f 1/1.175 → 0.85 · K 0.7 · Cm 15 · E 0.55 → Q **15.71**."""
|
||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||
CycleFactors,
|
||
hourly_output,
|
||
machine_hours_per_unit,
|
||
)
|
||
|
||
factors = CycleFactors(
|
||
"T", "T", "0201-0020", "굴삭기", Decimal("0.2"), Decimal("0.7"),
|
||
Decimal(1) / Decimal("1.175"), Decimal("0.55"), Decimal(15),
|
||
) # fmt: skip
|
||
assert hourly_output(factors) == Decimal("15.71") # 원값 15.708…
|
||
book = PriceBook()
|
||
book.add_title(PriceTitle("X", PriceKind.MACHINE_BASE, "중기", slots=_slots(Decimal(55700))))
|
||
book.add_title(PriceTitle("D", PriceKind.PRICE_BASIS, "단가산출"))
|
||
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])
|
||
|
||
|
||
def test_집계표_금액은_반올림_중기는_성분마다_반올림한_합() -> None:
|
||
"""집계표 넷 — 재료·노무·경비는 `반올림(수량 × 단가)`, 중기는 **성분마다 반올림한 뒤 합**(단가 열 없음).
|
||
|
||
실측 — 자원 셋 279/279 · 중기 131/144(합계 한 번에 반올림 107/144). 중기 짝은 목록표 명칭·규격으로 찾음
|
||
(같은 명칭·규격이 갈래로 둘인 기종은 짝이 흔들림 — 규칙 탓 아님).
|
||
"""
|
||
from B09_Estimation.B09_Estimation_Lists import machine_summary_amounts
|
||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||
|
||
resource, machine, machine_hits = 0, 0, 0
|
||
for _name, sheets in _workbooks():
|
||
for sheet in ("재료비수량금액집계표", "노무비수량금액집계표", "경비수량금액집계표"):
|
||
for row in sheets.get(sheet, []):
|
||
quantity, price, amount = _num(row[3]), _num(row[5]), _num(row[6])
|
||
if quantity is None or price is None or amount is None:
|
||
continue
|
||
resource += 1
|
||
assert round_at(quantity * price, OutputPlace.RESOURCE_SUMMARY) == amount, row
|
||
units = {
|
||
(row[1], row[2]): Money3(_num(row[6]), _num(row[5]), _num(row[7]))
|
||
for row in sheets.get("중기목록표", [])
|
||
if _num(row[4]) is not None
|
||
}
|
||
for row in sheets.get("중기시간금액집계표", []):
|
||
quantity = _num(row[3])
|
||
if quantity is None or (row[1], row[2]) not in units:
|
||
continue
|
||
machine += 1
|
||
parts = machine_summary_amounts(units[(row[1], row[2])], quantity)
|
||
got = (sum(parts.values()), parts["labor"], parts["material"], parts["expense"])
|
||
machine_hits += got == tuple(_num(v) or Decimal(0) for v in row[5:9])
|
||
if not resource:
|
||
pytest.skip("실무 원본 XLSX 가 없음")
|
||
assert resource >= 250 and machine >= 100, (resource, machine)
|
||
assert machine_hits >= machine * 0.9, (machine_hits, machine)
|