feat(b08,b09): 12-15 집수정 조립 — 콘크리트 집수정만 묶음(when) · 조각 구체콘크리트 ㎥ · 버림콘크리트(성분 없음 막힘) · 합판거푸집 #4회 · 철근 #보통(12-15 표 정본 · 간단 추정 걷음) · 판정표 비고 칸 인력으로 #버림콘크리트(87,489원/㎥) · 구체는 다짐기 없어 안 세우고 사유 · 뚜껑·설치비 사유 · 돌집수정 종전대로 · 묶음은 타설·잡석 줄이 또 안 셈 · 마스터 재생성(바뀐 공종 12-15 하나)

금액은 아직 안 서고 막힌 까닭이 드러나는 일감(브레인 ㉯ ⑴~⑸) · 배정 프로젝트 변화 0 · 전체 1962 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 19:38:27 +09:00
co-authored by Claude Opus 5
parent b23db36c78
commit 7c9f75ca12
10 changed files with 214 additions and 18 deletions
@@ -38,11 +38,11 @@ def _pickers(structure: dict[str, Any], mapping: WorkItemMapping) -> list[tuple[
"""이 구조물의 성분을 **이름으로 집는** 공종 줄 — (자리, 이름들). 집는 조건은 각 빌더와 같다."""
type_id = str(structure.get("type_id") or "")
entry = mapping.for_structure(type_id) or {}
composite = mapping.composite_for(type_id)
composite = mapping.composite_for(type_id, structure)
found: list[tuple[str, set[str]]] = []
if entry.get("billing_component"):
found.append(("구조물 줄", {str(entry["billing_component"])}))
if composite and not entry.get("work_item_code"):
if composite: # 걸린 묶음은 곧장 잇는 코드보다 위(빌더와 같은 조건)
for part in composite.get("parts") or []:
if isinstance(part, dict):
label = f"묶음 조각 「{part.get('name') or part.get('code')}"
@@ -271,13 +271,20 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
def composite_for(
self, type_id: str, structure: dict[str, Any] | None = None
) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
"""
options = (structure or {}).get("options") or {}
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
# `when` — 같은 종류라도 그 제원일 때만 묶음(콘크리트 집수정만 12-15 조립 · 09-14 ⑸).
when = row.get("when") or {}
if row.get("type_id") == type_id and all(
str(options.get(key) or "") == str(value) for key, value in when.items()
):
return row
return None
@@ -483,10 +490,12 @@ def rebar_complexity(
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]①{row.get('matched')}"
basis = row.get("basis") or "품셈 12-3 [주]①"
return str(row["class"]), f"{basis}{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]①{fallback.get('matched')}"
basis = fallback.get("basis") or "품셈 12-3 [주]①" # 그 공종 표가 직접 적으면 그 표(12-15)
return str(fallback["class"]), f"{basis}{fallback.get('matched')}"
from B08_Quantity.B08_Quantity_Wording import type_label
detail = f"({form})" if form else "(형식이 아직 입력되지 않음)"
@@ -433,7 +433,11 @@ def _structure_rows(
# m 수량에 곱해 **2.6배** 금액이 섰다(2026-09-08 실증). 어느 성분으로 세는지는
# 매핑이 말한다(`billing_component`) — 코드가 짐작하지 않는다.
billing = _component_billing(structure, entry)
composite = mapping.composite_for(type_id) if code is None else None
composite = mapping.composite_for(type_id, structure)
if composite:
code = None # 묶음이 걸리면 곧장 잇는 코드보다 위(콘크리트 집수정 12-15 조립 · 09-14)
if composite.get("outside_note"):
class_basis = " · ".join(p for p in (class_basis, composite["outside_note"]) if p)
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[dict[str, Any]] = []
@@ -600,7 +604,7 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
@@ -166,7 +166,7 @@ def rubble_base_rows(
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
if mapping.composite_for(str(structure.get("type_id") or ""), structure) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
@@ -55,7 +55,18 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"prefix": "합판거푸집",
"why": "원문 L6187 「기준수량(1회사용) · 사용횟수별기준수량에대한 비율(%) 재료별·노무비」",
},
"F0353": {
"code": "FP-12-15",
"shape": "remark_labor",
"prefix": "집수정",
# 구체콘크리트는 바로 아래 다짐기 줄과 한 갈래 — 다짐기가 안 풀리면 갈래를 안 세움(⑴).
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
}
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
#: 비율 줄 — 「1회사용시 2회사용시 …」 칸.
_RE_USE_COUNT = re.compile(r"(\d+)회사용시")
#: 값으로 안 읽는 줄 — 사용고재 평가기준(원문이 셈을 안 줌 · 사유는 `KNOWN_GAPS`) · 비고.
@@ -117,6 +128,8 @@ def match_judged_table(
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "use_count":
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
@@ -219,3 +232,39 @@ def _use_count(code, table, rows, catalog, basis, unit) -> list | str:
amount = base * ratio / Decimal(100) / basis
staged.append(_row(code, table, entry, amount, unit, index, f"{count}"))
return staged
def _remark_labor(code, table, judged, rows, catalog) -> list | str:
"""㎥ 줄 비고 칸의 인력(인/㎥)으로 갈래 — 다짐기가 딸린 갈래는 그 기계가 풀려야 세움."""
table_id = str(table.get("pum_table_id", ""))
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "")
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
pieces = []
for name, amount in labors:
entry = _entry(catalog, name, code)
if entry is None:
return f"{variant} 인력 「{name}"
pieces.append((entry, Decimal(amount)))
machine_name = judged.get("needs_machine", {}).get(variant)
if machine_name:
machine_cells = by_name.get("".join(machine_name.split())) or []
found_q = _RE_Q.search(" ".join(machine_cells))
entry = _entry(catalog, machine_name, code) if machine_cells else None
if entry is None or found_q is None:
reason = unmatched_reason(catalog, machine_name)
staged.append(UnmatchedRow(code, table_id, machine_name, reason))
why = (
f"다짐기 「{machine_name}」 가 안 풀려 갈래를 안 세움 — 인력만이면 조립 줄이"
" 조용히 싸짐(2026-09-14 ㉯ ⑴)"
)
staged.append(UnmatchedRow(code, table_id, variant, why))
continue
pieces.append((entry, Decimal(1) / Decimal(found_q.group(1))))
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
@@ -51,9 +51,10 @@
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "간단한 기초",
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
"class": "보통",
"matched": "철근가공조립(보통)",
"basis": "품셈 12-15 집수정 표",
"note": "그 공종 표가 직접 적은 「철근 | 철근가공조립(보통)」 이 정본 — 12-3 [주]① 「간단한 기초」 추정보다 위(2026-09-14 브레인 ㉯ ⑶ · 거푸집 4회와 같은 원칙). 앞서 간단으로 봤음."
}
],
"price_hint_krw_per_ton": {
@@ -327,6 +327,53 @@
"composite": {
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
"items": [
{
"type_id": "pipe_inlet_basin",
"when": {
"inlet_basin_material": "콘크리트"
},
"note": "2026-09-14 브레인 ㉯ ⑴~⑸ — 원문 L6460 12-15 집수정 표는 조립형(구체·버림 인력 · 다짐기 · 거푸집 합판4회 · 철근가공조립(보통)). 콘크리트 집수정만 묶음 · 돌집수정은 종전대로 12-15 곧장(⑸).",
"outside_note": "ⓘ 12-15 표의 집수정 뚜껑(스틸그레이팅 개)·설치비(재료비의 5%)는 조각 모양이 달라 묶음에 안 넣음(⑷ · 2026-09-14)",
"parts": [
{
"code": "FP-12-15#구체콘크리트",
"name": "구체콘크리트",
"unit": "㎥",
"from_components": [
"콘크리트"
]
},
{
"code": "FP-12-15#버림콘크리트",
"name": "버림콘크리트",
"unit": "㎥",
"from_components": [
"버림콘크리트"
],
"why": "□형 원단위(콘크리트 2.84㎥ = 벽 + 바닥기초)에 버림 성분이 없음 — 「설계에 없음 = 0」 으로 짓지 않고 막음(⑵)"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": [
"합판거푸집"
],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": [
"이형철근 D13",
"이형철근 D16"
],
"unit_from": "kg",
"kind_suffix": "rebar_complexity"
}
]
},
{
"type_id": "retaining_wall",
"parts": [
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T19:15:46+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "fa4d9bc80627d33a46b203f8910f9cc802c1249f2c6c8f09d2e818fce4332287",
"size_bytes": 838421
"sha256": "183a2933a2bf6f06fb99c0b86dc4073ec42c12a95784b3dd279f5eee50ee088f",
"size_bytes": 838487
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T18:26:57+09:00",
"generated_at": "2026-09-14T19:15:46+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -33148,7 +33148,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"버림콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33209,7 +33211,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-16",
@@ -0,0 +1,82 @@
"""12-15 집수정 조립 — 2026-09-14 브레인 ㉯ 남은 몫 판정 ⑴~⑸.
원문 L6460 12-15 표는 조립형(구체·버림 인력 비고 · 다짐기 · 거푸집 합판4회 ·
철근가공조립(보통) · 뚜껑 · 설치비 5%). B08 집수정을 12-15 곧장(개소) 이어 단가가 섰음.
다짐기(카탈로그 없음) 빠진 구체 갈래는 세움 인력만이면 조립 줄이 조용히 싸짐
원단위에 버림 성분 없음 성분 미확보 막음(0 지어냄)
철근 갈래는 12-15 보통(B08 추정 간단 걷음 · 4회와 같은 원칙)
뚜껑·설치비는 조각 모양이 달라 조립 · 사유만
돌집수정은 콘크리트 집수정 표가 아니라 종전대로
목적: 금액을 세워도 **조용히 사라지던 것이 사유로 드러남**.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
CONCRETE = {"structure_id": "b1", "type_id": "pipe_inlet_basin", "start_m": 50.0, "end_m": 50.0,
"options": {"inlet_basin_form": "□형(기본형)", "inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800"}} # fmt: skip
STONE = {"structure_id": "b2", "type_id": "pipe_inlet_basin", "start_m": 60.0, "end_m": 60.0,
"options": {"inlet_basin_form": "돌집수정 ㄷ형"}} # fmt: skip
def _basin(structure: dict) -> dict:
unit = build_table([structure], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
return next(r for r in handoff["work_items"] if r["origin"] == "structure")
def test_버림_갈래는_서고_구체_갈래는_다짐기_없어_안_섬() -> None:
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
build = cached_build()
assert "B-FP-12-15#구체콘크리트" not in build.book.titles
rows = detail_of(build, "B-FP-12-15#버림콘크리트")["rows"]
assert {r["ref_code"]: float(r["quantity"]) for r in rows} == {"1013": 0.15, "1002": 0.27}
left = " ".join(build.unattached.get("FP-12-15", []))
assert "구체콘크리트" in left and "봉상후렉시블" in left, left
def test_콘크리트_집수정은_조립_조각으로_가고_막힌_까닭이_드러남() -> None:
row = _basin(CONCRETE)
assert row["work_item_code"] is None, row
codes = {p["code"]: p for p in row["composite_parts"]}
assert codes["FP-12-15#구체콘크리트"]["quantity"] > 0
assert codes["FP-12-04#4회"]["quantity"] > 0
assert "FP-12-03#보통" in codes, list(codes) # ⑶ 표가 적은 갈래
reasons = " ".join(str(m.get("reason")) for m in row["composite_not_ready"] or [])
assert "FP-12-15#버림콘크리트" in {str(m.get("code")) for m in row["composite_not_ready"]}
assert reasons, row
assert "뚜껑" in row["spec_class_basis"] and "설치비" in row["spec_class_basis"] # ⑷
def test_돌집수정은_종전대로_12_15_곧장() -> None:
row = _basin(STONE)
assert row["work_item_code"] == "FP-12-15" and not row["composite_parts"], row
def test_콘크리트_집수정_콘크리트는_타설_줄이_또_세지_않음() -> None:
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
handoff = build_handoff(unit_quantity_table=unit)
placing = [
r for r in handoff["work_items"] if str(r["work_item_code"] or "").startswith("FP-12-01")
]
assert not placing, placing # 구체 조각이 셈 — 두 번 안 셈
def test_내역_조립_줄은_금액_없이_사유() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
unit = build_table([CONCRETE], {"pipe_inlet_basin": "집수정"})
bill = build_bill(build_handoff(unit_quantity_table=unit))
line = next(r for r in bill.rows if r.code is None and "집수정" in r.name)
assert line.amount_krw is None and "묶음 조각" in line.note, line.note