feat(B09): 인계 새 칸 7개 수용 + 묶음(옹벽) 줄 조립
B08 이 보내는 칸 중 **하나(`application_ratio_pct`)만 읽고 있었음** — 나머지는
조용히 버려지고 있었음. 메인이 「저장값이 안 닿아 늘 기본값으로 돌던」 결함을
잡은 것과 같은 자리라 대조해 발견
- 새로 읽는 칸: `quantity_gross` · `application_ratio_breakdown` ·
`quantity_breakdown` · `composite_parts` · `composite_not_ready` ·
`structure_kind`
- **묶음 줄 조립** — 품셈에 그 공종이 없는 것(옹벽)은 조각들의
`단가 × 조각수량` 합이 곧 그 줄의 1단위 단가. 조각이 하나라도 비면
**금액을 안 만들고** 사유를 그대로 적음
- `composite_not_ready` 만 온 줄도 묶음으로 봄 — 그러지 않으면 「코드 없음」으로
뭉뚱그려져 무엇이 없는지 안 보임
- 구조물 줄은 사유를 갈라 적음 — 「구조물 전개식(원단위)이 없어 조각을 못 세웠습니다」
실측(구조물 시험 프로젝트 5601e828): 23줄 · 합계 540,244원
돌쌓기(찰) 4줄이 「후보 3건」으로 뜸 — 뒷길이가 저장 안 돼 갈래를 못 고름.
임의로 고르지 않고 후보를 보임
옹벽은 「원단위 미확보 (retaining_wall H=2.5) — 표에 있는 규격: 반중력식 H=2.0」
으로 사유째 뜸
㉥ 가드 오탐 없음(메인이 택한 아랫단 + 물구멍 자재 조합에서 안 걸림)
검증: pytest 190 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,16 @@ class HandoffWorkItem:
|
||||
ground_class: str = ""
|
||||
#: 반영률(%) — B08 이 이미 곱했으면 산출근거에만 적고 **여기서 또 곱하지 않는다**.
|
||||
application_ratio_pct: Decimal | None = None
|
||||
#: 반영률 적용 **전** 수량. 산출근거에만 쓴다.
|
||||
quantity_gross: Decimal | None = None
|
||||
#: 성·절토처럼 율이 갈리는 경우의 몫별 율·수량 — 문장 파싱 없이 그대로 그린다.
|
||||
application_ratio_breakdown: dict | None = None
|
||||
quantity_breakdown: dict | None = None
|
||||
#: 묶음 줄(옹벽처럼 품셈에 그 공종이 없는 것) — 무엇으로 이루어지는지.
|
||||
composite_parts: tuple = ()
|
||||
#: 묶음인데 아직 못 채운 조각 — 「단가 없음」과 「물량 없음」을 갈라 적는다.
|
||||
composite_not_ready: tuple = ()
|
||||
structure_kind: str = ""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
@@ -192,6 +202,12 @@ def parse_handoff(payload: dict[str, Any]) -> tuple[list[HandoffWorkItem], list[
|
||||
haul_equipment=row.get("haul_equipment"),
|
||||
# ⚠ 있으면 **적기만** 한다 — 곱하기는 B08 한 곳에서만(2026-09-08 이견 ①).
|
||||
application_ratio_pct=_decimal(row.get("application_ratio_pct"), None),
|
||||
quantity_gross=_decimal(row.get("quantity_gross"), None),
|
||||
application_ratio_breakdown=row.get("application_ratio_breakdown"),
|
||||
quantity_breakdown=row.get("quantity_breakdown"),
|
||||
composite_parts=tuple(row.get("composite_parts") or ()),
|
||||
composite_not_ready=tuple(row.get("composite_not_ready") or ()),
|
||||
structure_kind=row.get("structure_kind") or "",
|
||||
)
|
||||
for row in payload["work_items"]
|
||||
]
|
||||
@@ -271,6 +287,7 @@ def build_bill(
|
||||
# 정렬은 마스터의 `sort_order`(256 간격)를 그대로 따른다 — 우리가 다시 매기지 않는다.
|
||||
used: list[tuple[tuple[int, ...], HandoffWorkItem, list[_MasterNode]]] = []
|
||||
orphans: list[HandoffWorkItem] = []
|
||||
composites: list[HandoffWorkItem] = []
|
||||
for item in work_items:
|
||||
if not item.in_bill:
|
||||
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
|
||||
@@ -278,6 +295,11 @@ def build_bill(
|
||||
# 줄」로 잘못 읽힌다.
|
||||
result.excluded.append(_excluded_row(item))
|
||||
continue
|
||||
if (item.composite_parts or item.composite_not_ready) and not item.work_item_code:
|
||||
# 묶음 줄 — 품셈에 그 공종이 없어 **조각을 합쳐** 한 줄로 세운다
|
||||
# (옹벽 = 타설 + 거푸집 + 철근 + 잡석). 「코드 없음」으로 세면 안 된다.
|
||||
composites.append(item)
|
||||
continue
|
||||
if not item.work_item_code or item.work_item_code not in index:
|
||||
orphans.append(item)
|
||||
continue
|
||||
@@ -321,14 +343,26 @@ def build_bill(
|
||||
emitted.setdefault(leaf.code, item_no)
|
||||
result.rows.append(_leaf_row(item_no, leaf, item, unit_prices, result))
|
||||
|
||||
# ── 1-2) 묶음 줄 ──────────────────────────────────────────────────────────
|
||||
for item in composites:
|
||||
counters[""] = counters.get("", 0) + 1
|
||||
result.rows.append(_composite_row(str(counters[""]), item, unit_prices, result))
|
||||
|
||||
# ── 2) 공종을 못 고른 줄 — 이름째 남긴다 ────────────────────────────────────
|
||||
for item in orphans:
|
||||
# 구조물 줄은 사유가 다르다 — 품셈에 그 공종이 없어 **전개식(원단위)** 이 있어야
|
||||
# 조각으로 설 수 있다. 「코드가 없다」로만 적으면 무엇을 해야 하는지 안 보인다.
|
||||
reason = (
|
||||
"구조물 전개식(원단위)이 없어 조각을 못 세웠습니다 — B08 원단위 필요."
|
||||
if item.origin == "structure"
|
||||
else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다."
|
||||
)
|
||||
result.missing.append(
|
||||
{
|
||||
"name": item.display_name,
|
||||
"unit": item.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다.",
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -380,6 +414,77 @@ def _haul_price_of(item: HandoffWorkItem, result: BillResult) -> Decimal:
|
||||
return _ZERO
|
||||
|
||||
|
||||
def _composite_row(
|
||||
item_no: str,
|
||||
item: HandoffWorkItem,
|
||||
unit_prices: UnitPriceBuild,
|
||||
result: BillResult,
|
||||
) -> BillRow:
|
||||
"""묶음 줄 — 조각들의 `단가 × 조각수량` 을 더해 **1단위 단가**를 만든다.
|
||||
|
||||
옹벽처럼 품셈에 그 공종이 없는 것은 **조각을 합친 것이 곧 그 줄의 단가**다
|
||||
(PLAN 9-3 「제목 + 상세」 한 쌍). 조각이 하나라도 비면 **금액을 만들지 않는다** —
|
||||
절반짜리 단가가 서는 것이 가장 위험하다.
|
||||
"""
|
||||
row = BillRow(
|
||||
item_no=item_no,
|
||||
level=1,
|
||||
code=None,
|
||||
name=item.name,
|
||||
spec=item.spec,
|
||||
unit=item.unit,
|
||||
quantity=item.quantity,
|
||||
in_bill=item.in_bill,
|
||||
)
|
||||
missing_parts: list[str] = []
|
||||
money = None
|
||||
for part in item.composite_parts:
|
||||
code = str(part.get("code") or "")
|
||||
amount = _decimal(part.get("quantity"), None)
|
||||
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)
|
||||
money = scaled if money is None else money + scaled
|
||||
|
||||
reasons: list[str] = []
|
||||
for pending in item.composite_not_ready:
|
||||
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
|
||||
if isinstance(pending, str):
|
||||
missing_parts.append(pending)
|
||||
continue
|
||||
label = str(pending.get("code") or pending.get("name") or "이름 없음")
|
||||
why = str(pending.get("reason") or pending.get("note") or "")
|
||||
missing_parts.append(label)
|
||||
if why:
|
||||
reasons.append(f"{label}: {why}")
|
||||
|
||||
if missing_parts or money is None:
|
||||
detail_text = "; ".join(reasons[:3]) or ", ".join(missing_parts[:4])
|
||||
row.note = f"묶음 조각이 덜 찼습니다 — {detail_text}"
|
||||
result.missing.append(
|
||||
{
|
||||
"name": row.name,
|
||||
"unit": row.unit,
|
||||
"quantity": str(item.quantity),
|
||||
"reason": (
|
||||
f"묶음 조각 미확보 ({len(missing_parts)}건)"
|
||||
+ (f" — {reasons[0]}" if reasons else "")
|
||||
),
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
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.note = f"묶음 {len(item.composite_parts)}조각 합계"
|
||||
return row
|
||||
|
||||
|
||||
def _excluded_row(item: HandoffWorkItem) -> BillRow:
|
||||
"""검산용 줄(`in_bill=false`). **수량만 보이고 단가·금액을 안 붙인다.**"""
|
||||
return BillRow(
|
||||
|
||||
Reference in New Issue
Block a user