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
This commit is contained in:
@@ -45,6 +45,8 @@ _ZERO = Decimal(0)
|
||||
#: 총 절취량으로 셀 공종 — 9-3 토사깎기 · 9-4 암절취 · 9-5 발파암(인계 대응표 「흙깎기」 셋).
|
||||
#: ⚠ 판에 묶인 절 번호임(명세 17장) — 판이 바뀌면 대응표와 함께 고칠 것.
|
||||
CUT_CODE_PREFIXES = ("FP-09-03", "FP-09-04", "FP-09-05")
|
||||
#: 구조물도 양식 호표 코드 머리(명세 2장 자체 확장) — 금액은 B08 일위대가 엔진을 B09 단가표로 셈.
|
||||
STRUCTURE_PRICE_PREFIX = "AX-ST-"
|
||||
|
||||
#: 자재 공급 구분이 안 갈린 값. B08 이 실제로 이 값을 보낸다(2026-09-08 실물 확인).
|
||||
#: **관급자재대에도, 도급 재료비에도 넣지 않는다** — 어느 쪽에 넣어도 총액이 틀린다.
|
||||
@@ -228,6 +230,8 @@ class BillResult:
|
||||
price_basis: Any = None
|
||||
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
|
||||
material_sheet: Any = None
|
||||
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
|
||||
unconfirmed: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def direct_material_krw(self) -> Decimal:
|
||||
@@ -357,6 +361,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
|
||||
_excluded_row,
|
||||
_leaf_row,
|
||||
_material_row,
|
||||
_structure_price_row,
|
||||
)
|
||||
|
||||
|
||||
@@ -365,8 +370,13 @@ def build_bill(
|
||||
*,
|
||||
build: UnitPriceBuild | None = None,
|
||||
master: dict[str, Any] | None = None,
|
||||
structure_prices: dict[str, dict[str, Any]] | None = None,
|
||||
) -> BillResult:
|
||||
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다."""
|
||||
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
|
||||
|
||||
`structure_prices` — 구조물도 호표 금액 `{B-AX-ST-…#키: …}`(B08 `structure_bill_prices`,
|
||||
**이 `build` 단가표로** 셈). 안 주면 호표 줄은 금액 없이 사유와 함께 섬.
|
||||
"""
|
||||
work_items, materials = parse_handoff(payload)
|
||||
unit_prices = build or cached_build()
|
||||
index = _master_index(master or load_work_item_master())
|
||||
@@ -377,6 +387,7 @@ def build_bill(
|
||||
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
|
||||
orphans: list[HandoffWorkItem] = []
|
||||
composites: list[HandoffWorkItem] = []
|
||||
templated: list[HandoffWorkItem] = []
|
||||
for item in work_items:
|
||||
if not item.in_bill:
|
||||
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
|
||||
@@ -397,6 +408,10 @@ def build_bill(
|
||||
}
|
||||
)
|
||||
continue
|
||||
if str(item.work_item_code or "").startswith(STRUCTURE_PRICE_PREFIX):
|
||||
# 구조물도 호표(PLAN 6장 ②) — 공종 마스터에 없는 자체 확장 코드라 나무에 안 끼움.
|
||||
templated.append(item)
|
||||
continue
|
||||
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
||||
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
||||
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
||||
@@ -455,6 +470,14 @@ def build_bill(
|
||||
row_of[id(item)] = _composite_row(str(counters[""]), item, unit_prices, result)
|
||||
result.rows.append(row_of[id(item)])
|
||||
|
||||
# ── 1-3) 구조물도 호표 줄 ─────────────────────────────────────────────────
|
||||
for item in templated:
|
||||
counters[""] = counters.get("", 0) + 1
|
||||
row_of[id(item)] = _structure_price_row(
|
||||
str(counters[""]), item, structure_prices or {}, result
|
||||
)
|
||||
result.rows.append(row_of[id(item)])
|
||||
|
||||
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
|
||||
for item in orphans:
|
||||
# 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야
|
||||
@@ -569,6 +592,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
|
||||
"material_rows": len(result.material_rows),
|
||||
"material_sheet": result.material_sheet.as_dict() if result.material_sheet else None,
|
||||
"missing": result.missing,
|
||||
"unconfirmed": result.unconfirmed,
|
||||
"unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed),
|
||||
"body_total_krw": str(result.body_total_krw),
|
||||
"direct_material_krw": str(result.direct_material_krw),
|
||||
"direct_labor_krw": str(result.direct_labor_krw),
|
||||
|
||||
@@ -95,6 +95,65 @@ def _composite_row(
|
||||
return row
|
||||
|
||||
|
||||
def _structure_price_row(
|
||||
item_no: str,
|
||||
item: HandoffWorkItem,
|
||||
structure_prices: dict[str, dict],
|
||||
result: BillResult,
|
||||
) -> BillRow:
|
||||
"""구조물도 호표 줄(PLAN 6장 ②) — m당 금액은 B08 일위대가 엔진을 **이 내역의 단가표**로 돌린 값.
|
||||
|
||||
⚠ 일위대가에 막힌 줄이 있으면 **금액을 안 세움** — 절반짜리 단가가 제일 위험(묶음 줄과 같음).
|
||||
⚠ 수동 단가로 선 줄은 금액을 세우되 「미확정」으로 셈 — 구조물도 화면과 내역이 같은 값.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructurePriceLink import structure_price_code
|
||||
|
||||
ref = structure_price_code(str(item.work_item_code), item.variant_value)
|
||||
row = BillRow(
|
||||
item_no=item_no,
|
||||
level=1,
|
||||
code=item.work_item_code,
|
||||
name=item.name,
|
||||
spec=item.spec,
|
||||
unit=item.unit,
|
||||
quantity=item.quantity,
|
||||
in_bill=item.in_bill,
|
||||
)
|
||||
entry = structure_prices.get(ref)
|
||||
reason = ""
|
||||
if entry is None:
|
||||
reason = f"구조물도 일위대가 {ref} 를 못 받음 — 구조물도 탭에서 그 장의 일위대가를 확인"
|
||||
elif entry["blocked"]:
|
||||
reason = f"구조물도 일위대가 미완 — 막힌 줄 {entry['blocked']}: " + "; ".join(
|
||||
entry["reasons"][:3]
|
||||
)
|
||||
if reason:
|
||||
row.add_note("unit_price_krw", reason)
|
||||
result.missing.append(
|
||||
{
|
||||
"name": row.name,
|
||||
"code": ref,
|
||||
"unit": row.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
money = entry["money"]
|
||||
line = money.scaled(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.material_krw = line.material
|
||||
row.labor_krw = line.labor
|
||||
row.expense_krw = line.expense
|
||||
row.add_note("unit_price_krw", f"호표 {ref}")
|
||||
if entry["unconfirmed"]:
|
||||
row.add_note("unit_price_krw", f"⚠ 수동 단가 {entry['unconfirmed']}건 미확정")
|
||||
result.unconfirmed.append({"name": row.name, "code": ref, "count": entry["unconfirmed"]})
|
||||
return row
|
||||
|
||||
|
||||
def _excluded_row(item: HandoffWorkItem) -> BillRow:
|
||||
"""`in_bill=false` 줄. **수량만 보이고 단가·금액을 안 붙인다.**
|
||||
|
||||
|
||||
@@ -753,8 +753,9 @@ async def get_bill(project_id: UUID) -> JSONResponse:
|
||||
⚠ 단가가 없거나 밑수를 모르는 줄은 **0 으로 안 때우고** `missing` 으로 드러낸다 —
|
||||
화면이 그 목록을 그대로 보인다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Router_Material import get_handoff
|
||||
from B08_Quantity.B08_Quantity_Router_Material import get_handoff, structure_bill_prices
|
||||
|
||||
build = cached_build()
|
||||
try:
|
||||
response = await get_handoff(project_id)
|
||||
payload = json.loads(bytes(response.body).decode("utf-8"))
|
||||
@@ -767,9 +768,16 @@ async def get_bill(project_id: UUID) -> JSONResponse:
|
||||
if "work_items" not in payload:
|
||||
# B08 이 오류 응답을 준 경우 — 그 사유를 그대로 넘긴다(감추지 않는다).
|
||||
return JSONResponse(status_code=502, content={"status": "error", **payload})
|
||||
try:
|
||||
# 구조물도 호표 금액 — **이 내역과 같은 단가표**로(PLAN 6장 ②).
|
||||
structure_prices = await structure_bill_prices(project_id, build)
|
||||
except Exception:
|
||||
# 못 세면 호표 줄이 금액 없이 「못 받음」 사유로 섬 — 조용히 0 원 안 됨.
|
||||
logger.exception("B09 내역서 — 구조물도 호표 금액 실패: project_id=%s", project_id)
|
||||
structure_prices = {}
|
||||
|
||||
try:
|
||||
result = build_bill(payload)
|
||||
result = build_bill(payload, build=build, structure_prices=structure_prices)
|
||||
except DoubleCountError as error:
|
||||
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
|
||||
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
|
||||
|
||||
Reference in New Issue
Block a user