Files
Aislo/resources/tester/test_b09_structure_price_link.py
T
eomsangdonandClaude Opus 5 c2e3c4cda2 feat(b09): 구조물도 일위대가를 내역으로 받는 문 — 호표 B-AX-ST-<8hex>#<제원 키>
- 양식 일위대가로 셀 구조물도 장은 인계 줄 하나(코드 AX-ST · 갈래 = 제원 키 · 수량 연장 m)
- 양식이 품은 줄(기초잡석)은 따로 인계 안 함 — 이중계상 막음
- B09 내역이 자기 단가표로 B08 일위대가 엔진을 돌려 금액 · 못 받거나 막히면 사유
- 수동 단가도 씀 — 내역 요약에 미확정 N건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:20:26 +09:00

129 lines
5.8 KiB
Python

"""구조물도 일위대가 → B09 내역 받는 문 (2026-09-13, PLAN 6장 첫 일감 ② · 브레인 판정).
겨누는 것
① 양식 일위대가로 셀 구조물은 인계 줄 하나 = 코드 AX-ST · 갈래 = 제원 키 · 수량 = 연장 m
② 양식이 품은 기초잡석은 따로 인계 안 함(이중계상) — 양식 장이 없으면 종전대로 섬
③ 제원 키는 제목 글이 아니라 제원 값 — 연장이 달라도 같은 키 · 뒷길이가 다르면 다른 키
④ B09 내역 — 호표 금액 × 연장 · 금액 못 받으면 사유 · 수동 단가는 미확정
"""
from __future__ import annotations
from decimal import Decimal
from functools import lru_cache
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import (
price_sheets,
priced_sheets,
structure_price_code,
)
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates, load_template
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_summary, build_bill
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices, find_variant_code
@lru_cache(maxsize=1)
def _build():
return build_unit_prices()
def _wall(sid: str, start: float, end: float, back: int = 45) -> dict:
return StructureInstance.model_validate(
{
"structure_id": sid,
"type_id": "masonry_wet",
"placement": "interval",
"start_m": start,
"end_m": end,
"options": {"height_m": 2.5, "back_len_cm": back},
}
).model_dump()
def _chain(walls: list[dict]) -> tuple[dict, list[dict]]:
table = build_table(walls, {"masonry_wet": "돌쌓기(찰)"}, {}, {}, None)
payload = build_standard_sheets(table, {})
apply_templates(payload, {}, {})
entries = priced_sheets(payload["sheets"], {"masonry_wet": load_template("masonry_wet")})
return table, entries
def _rows(handoff: dict, prefix: str) -> list[dict]:
return [
r for r in handoff["work_items"] if str(r.get("work_item_code") or "").startswith(prefix)
]
def test_양식_장_구조물은_호표_줄_하나로_가고_기초잡석은_따로_안_간다() -> None:
table, entries = _chain([_wall("a", 0, 10), _wall("b", 50, 56)])
assert len(entries) == 1 and entries[0]["structure_ids"] == ["a", "b"]
assert "기초잡석" in entries[0]["covered"] and "모르터" in entries[0]["covered"]
before = build_handoff(unit_quantity_table=table)
assert _rows(before, "FP-13-04-05") and _rows(before, "FP-12-25") # 종전 — 돌쌓기 ㎡ + 잡석
after = build_handoff(unit_quantity_table=table, priced_sheets=entries)
ax = _rows(after, "AX-ST-")
assert [(r["unit"], r["quantity"]) for r in ax] == [("m", 10.0), ("m", 6.0)]
assert {r["variant_axis"] for r in ax} == {"structure_sheet"}
assert not _rows(after, "FP-13-04-05") and not _rows(after, "FP-12-25")
assert set(ax[0]) == set(before["work_items"][0]) # 인계 칸 계약 그대로
def test_제원_키는_제원_값이고_연장과_제목은_안_든다() -> None:
_table, entries = _chain([_wall("a", 0, 10), _wall("c", 20, 30, back=55)])
keys = {e["structure_ids"][0]: e["key"] for e in entries}
assert "L3=45" in keys["a"] and "L3=55" in keys["c"] and "|L=" not in keys["a"]
assert keys["a"] != keys["c"]
assert structure_price_code("AX-ST-56e81a2c", "A = 1 | B~2").endswith("#A=1|B~2")
def _bill(manual: dict | None = None):
build = _build()
table, entries = _chain([_wall("a", 0, 10)])
handoff = build_handoff(unit_quantity_table=table, priced_sheets=entries)
prices = price_sheets(
entries,
build.book,
lambda code, value: find_variant_code(code, value, build),
[load_template("masonry_wet")],
manual,
)
return entries, prices, build_bill(handoff, build=build, structure_prices=prices)
def test_내역은_호표_금액에_연장을_곱한다() -> None:
entries, prices, bill = _bill()
ref = structure_price_code(entries[0]["code"], entries[0]["key"])
entry = prices[ref]
row = next(r for r in bill.rows if r.code == entries[0]["code"])
# 모르터 공종 코드가 선 뒤(랩탑 메인 AX-WK) 찰쌓기 호표 세 줄이 다 섬.
assert not entry["blocked"], entry["reasons"]
assert row.unit_price_krw == round_at(entry["money"].total, OutputPlace.UNIT_PRICE_ROW)
assert row.amount_krw == round_at(entry["money"].scaled(Decimal(10)).total, OutputPlace.BOQ_ROW)
assert row.quantity == Decimal(10) and f"호표 {ref}" in row.note
def test_금액을_못_받으면_사유로_서고_수동_단가는_미확정으로_센다() -> None:
build = _build()
table, entries = _chain([_wall("a", 0, 10)])
handoff = build_handoff(unit_quantity_table=table, priced_sheets=entries)
missing = build_bill(handoff, build=build, structure_prices={})
row = next(r for r in missing.rows if r.code == entries[0]["code"])
assert row.amount_krw is None and "못 받음" in row.note
# 모든 일위대가 줄에 수동 단가 — 막힘 없이 서고 미확정 3건.
manual = {
"masonry_wet": {str(s): {"material": 1000, "labor": 0, "expense": 0} for s in (1, 2, 3)}
}
_entries, _prices, bill = _bill(manual)
row = next(r for r in bill.rows if r.code == entries[0]["code"])
assert row.amount_krw is not None and "미확정" in row.note
summary = bill_summary(bill)
assert summary["unconfirmed_count"] == 3 and summary["unconfirmed"][0]["count"] == 3