diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py index 983589c4..522101c6 100644 --- a/B08_Quantity/B08_Quantity_Engine_Handoff.py +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -50,6 +50,11 @@ from common_util.common_util_quantity_spread import spread_by_unit DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" DATASET_PREFIX = "work_item_mapping_" +#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이 +#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다. +REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar" +REBAR_PREFIX = "rebar_complexity_" + #: 줄이 어디서 왔나 — 되짚을 때 쓴다. ORIGIN_EARTHWORK = "earthwork" ORIGIN_STRUCTURE = "structure" @@ -92,6 +97,7 @@ class WorkItemMapping: pending_user: dict[str, Any] = field(default_factory=dict) composite: dict[str, Any] = field(default_factory=dict) concrete_placing: dict[str, Any] = field(default_factory=dict) + unit_conversion: dict[str, Any] = field(default_factory=dict) def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401 """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" @@ -143,9 +149,75 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping: pending_user=payload.get("pending_user") or {}, composite=payload.get("composite") or {}, concrete_placing=payload.get("concrete_placing") or {}, + unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {}, ) +def composite_quantities( + structure: dict[str, Any], + composite: dict[str, Any], + mapping: WorkItemMapping, +) -> tuple[list[dict[str, Any]], list[str]]: + """묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유). + + ⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로 + 못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다. + ⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다. + 안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다. + """ + amounts: dict[str, tuple[float, str]] = {} + for component in structure.get("components") or []: + name = str(component.get("name") or "").strip() + amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or "")) + kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001) + + parts: list[dict[str, Any]] = [] + missing: list[str] = [] + for spec in composite.get("parts") or []: + if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다 + parts.append({"code": str(spec)}) + continue + sources = list(spec.get("from_components") or []) + found = [name for name in sources if name in amounts] + total = sum(amounts[name][0] for name in found) + if spec.get("unit_from") == "kg" and spec.get("unit") == "ton": + total *= kg_to_ton + kinds = { + component.get("basis_kind") + for component in structure.get("components") or [] + if str(component.get("name") or "").strip() in found + } + suffix = spec.get("kind_suffix") + entry: dict[str, Any] = { + "code": spec.get("code"), + "name": spec.get("name"), + "unit": spec.get("unit"), + "quantity": total if found else None, + # 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다. + "basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None), + "from_components": sources, + } + if suffix == "rebar_complexity": + # 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다. + complexity, why = rebar_complexity( + str(structure.get("type_id") or ""), structure.get("options") or {} + ) + entry["kind"] = complexity + entry["kind_basis"] = why + if complexity: + entry["code"] = f"{spec.get('code')}#{complexity}" + else: + entry["not_ready"] = True + entry["why"] = why + missing.append(f"{spec.get('name')}: {why}") + if spec.get("not_ready") or not found: + entry["not_ready"] = True + entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음") + missing.append(f"{spec.get('name') or spec.get('code')}: {entry['why']}") + parts.append(entry) + return parts, missing + + def structure_kind(structure: dict[str, Any]) -> str: """콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.** @@ -169,6 +241,44 @@ def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | No return codes.get(default), True +def load_rebar_table(path: Path | None = None) -> dict[str, Any]: + """철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다.""" + target = path + if target is None: + files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def rebar_complexity( + type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None +) -> tuple[str | None, str]: + """(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`. + + 품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**· + 교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」. + 사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.** + """ + found = table if table is not None else load_rebar_table() + form = options.get("form") + fallback: dict[str, Any] | None = None + for row in found.get("form_map") or []: + if row.get("type_id") != type_id: + continue + if "form" not in row: + fallback = row + continue + if row.get("form") == form: + if row.get("class"): + return str(row["class"]), f"품셈 12-3 [주]① 「{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')}」" + return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음" + + def _spec_detail(structure: dict[str, Any]) -> str: """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" parts: list[str] = [] @@ -307,6 +417,10 @@ def _structure_rows( code = entry.get("work_item_code") composite = mapping.composite_for(type_id) if code is None else None kind = structure_kind(structure) if composite else None + parts: list[dict[str, Any]] | None = None + parts_missing: list[str] = [] + if composite: + parts, parts_missing = composite_quantities(structure, composite, mapping) if code is None and composite is None: unmatched.append(f"구조물({type_id})") length = float(structure.get("length_m") or 0.0) @@ -328,11 +442,11 @@ def _structure_rows( "station_to": structure.get("end_m"), "spec_detail": _spec_detail(structure), # 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다. - "composite_parts": (composite or {}).get("parts"), + "composite_parts": parts, # 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다. "structure_kind": kind, - # ⚠ 아직 일위대가가 안 선 공종 — 지금 세우면 절반짜리가 된다. - "composite_not_ready": (composite or {}).get("not_ready"), + # ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다. + "composite_not_ready": parts_missing or None, "in_bill": True, "in_bill_reason": (composite or {}).get("why", ""), "origin": ORIGIN_STRUCTURE, diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 8e7ea4a1..e0fc9591 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -127,6 +127,9 @@ class StructureQuantity: # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). start_m: float | None = None end_m: float | None = None + # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. + # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). + options: dict[str, Any] = field(default_factory=dict) components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) @@ -353,6 +356,7 @@ def expand( height_m=height, start_m=start if structure.get("start_m") is not None else None, end_m=end if structure.get("end_m") is not None else None, + options=dict(options), ) expander = EXPANDERS.get(type_id) if expander is None: @@ -420,6 +424,8 @@ def build_table( "height_m": item.height_m, "start_m": item.start_m, "end_m": item.end_m, + # 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다. + "options": item.options, "notes": item.notes, "components": [ { diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 53c2b928..9d347c76 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -100,10 +100,22 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: unit_table, supply_map=settings.get("material_supply") or {}, ) + # 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다. + handoff = build_handoff(unit_quantity_table=unit_table) + composite = [ + { + "name": row["name"], + "parts": row["composite_parts"], + "not_ready": row.get("composite_not_ready"), + } + for row in handoff["work_items"] + if row.get("composite_parts") + ] return JSONResponse( content={ "unit_quantity": unit_table, "material": material_table, + "composite": composite, "skipped_structures": skipped, "structure_count": len(structures), } diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts index 3da8cfa0..16872f43 100644 --- a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -61,6 +61,20 @@ export interface UnitQuantityStructure { }[]; } +/** 묶음으로 서는 구조물의 조각. 품셈에 그 이름의 공종이 없어 여러 공종으로 나뉜다. */ +export interface CompositePart { + code: string | null; + name?: string; + unit?: string; + quantity: number | null; + basis_kind?: string | string[] | null; + /** 철근 갈래(간단/보통/복잡/매우복잡) — 품셈 원문이 정한다. */ + kind?: string | null; + kind_basis?: string; + not_ready?: boolean; + why?: string; +} + export interface MaterialResponse { unit_quantity: { structures: UnitQuantityStructure[]; @@ -72,6 +86,12 @@ export interface MaterialResponse { material: MaterialTable; skipped_structures: string[]; structure_count: number; + /** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */ + composite?: { + name: string; + parts: CompositePart[]; + not_ready?: string[] | null; + }[]; } /** 거푸집·동바리 안내에 쓰는 값. */ @@ -321,6 +341,25 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement wrap.append(line); } + // ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로 + // 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다. + for (const group of response.composite ?? []) { + const box = document.createElement("p"); + box.className = "b08-quantity__notice"; + const parts = group.parts.map((part) => { + const kind = part.kind ? `#${part.kind}` : ""; + const amount = + part.quantity === null || part.quantity === undefined + ? "-" + : `${num(part.quantity, 3)}${part.unit ?? ""}`; + return `${part.name ?? part.code}${kind} ${amount}${part.not_ready ? " ⚠" : ""}`; + }); + box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`; + wrap.append(box); + const blocked = warning("⚠ 물량을 못 채운 조각", group.not_ready ?? []); + if (blocked) wrap.append(blocked); + } + if (!unit.structures.length) { const empty = document.createElement("p"); empty.className = "b08-quantity__message"; diff --git a/resources/data_rebar/rebar_complexity_2026-01-01.json b/resources/data_rebar/rebar_complexity_2026-01-01.json new file mode 100644 index 00000000..acdced1a --- /dev/null +++ b/resources/data_rebar/rebar_complexity_2026-01-01.json @@ -0,0 +1,70 @@ +{ + "schema_version": "1.0", + "dataset_id": "rebar_complexity", + "effective_date": "2026-01-01", + "note": "철근 가공·조립 갈래(간단/보통/복잡/매우복잡) — **품셈 12-3 [주]① 원문**이 구조물을 예시로 갈라 둔다. 사람이 고르는 값이 아니라 법으로 정해지는 값이다(거푸집 사용횟수 1-7-1 과 같은 자리).", + "source": { + "doc": "산림사업 표준품셈 12-3 철근 현장가공 및 조립 [주] ①", + "table_id": "F0335", + "quote": "간단한 것이란 측구, 간단한 기초 및 중력식 옹벽 등을 말하며, 보통의 것이란 수문, 반중력식 옹벽 및 교대 등을 말하고, 복잡한 것이란 교량의 슬래브, 암거, 우물통 부벽식 옹벽 등을 말하며, 매우 복잡한 것이란 구주식(기둥형) 교대, 교각, 지하철, 터널" + }, + "policy": { + "auto_decided": true, + "unlisted_is_flagged": true, + "note": "원문 예시에 걸리는 것만 정한다. 안 걸리면 「갈래 미확보」로 드러내고 임의로 고르지 않는다." + }, + "classes": [ + { "key": "간단", "examples": ["측구", "간단한 기초", "중력식 옹벽"] }, + { "key": "보통", "examples": ["수문", "반중력식 옹벽", "교대"] }, + { "key": "복잡", "examples": ["교량 슬래브", "암거", "우물통", "부벽식 옹벽"] }, + { "key": "매우복잡", "examples": ["구주식(기둥형) 교대", "교각", "지하철", "터널"] } + ], + "form_map": [ + { + "type_id": "retaining_wall", + "form": "중력식", + "class": "간단", + "matched": "중력식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "반중력식", + "class": "보통", + "matched": "반중력식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "부벽식", + "class": "복잡", + "matched": "부벽식 옹벽" + }, + { + "type_id": "retaining_wall", + "form": "캔틸레버식", + "class": null, + "why": "원문 [주]① 예시에 캔틸레버식 옹벽이 없음 — 임의로 고르지 않고 미확보로 드러냄" + }, + { + "type_id": "box_culvert", + "class": "복잡", + "matched": "암거" + }, + { + "type_id": "pipe_inlet_basin", + "class": "간단", + "matched": "간단한 기초", + "note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요" + } + ], + "price_hint_krw_per_ton": { + "note": "⚠ **표시 전용.** 갈래를 자동으로 정하더라도 화면에 갈래 이름과 차이를 보여야 사람이 검증할 수 있다. B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).", + "computed_by": "B09", + "computed_on": "2026-09-07", + "values": { + "간단": 919146.7, + "보통": 1032497.4, + "복잡": 1143569.7, + "매우복잡": 1278375.3 + } + } +} diff --git a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json index f00b08f0..66632217 100644 --- a/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -142,12 +142,19 @@ "items": [ { "group": "지장목제거", - "candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"], + "candidates": [ + "FP-04-01 수확베기", + "FP-04-02 단목베기", + "FP-04-03 위험목 베기" + ], "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" }, { "group": "흙깎기/측구터파기 암", - "candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"], + "candidates": [ + "FP-09-04 암절취(리핑)", + "FP-09-05 발파암" + ], "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" } ] @@ -158,18 +165,62 @@ { "type_id": "retaining_wall", "parts": [ - "FP-12-01-01#철근구조물", - "FP-12-04 합판거푸집", - "FP-12-03 철근 현장가공 및 조림", - "FP-12-25 기초잡석" + { + "code": "FP-12-01-01", + "name": "콘크리트 타설", + "unit": "㎥", + "from_components": [ + "콘크리트", + "버림콘크리트" + ], + "kind_suffix": "concrete_placing" + }, + { + "code": "FP-12-04", + "name": "합판거푸집", + "unit": "㎡", + "from_components": [ + "합판거푸집" + ] + }, + { + "code": "FP-12-38", + "name": "유로폼", + "unit": "㎡", + "from_components": [ + "유로폼" + ] + }, + { + "code": "FP-12-03", + "name": "철근 현장가공 및 조립", + "unit": "ton", + "from_components": [ + "이형철근 D13", + "이형철근 D16" + ], + "unit_from": "kg", + "kind_suffix": "rebar_complexity" + }, + { + "code": "FP-12-25", + "name": "기초잡석", + "unit": "㎥", + "from_components": [], + "not_ready": true, + "why": "관측 원단위에 기초잡석 물량이 없음 — 울진 라이브러리 반중력식 H=2.0 항목에 그 줄이 없다" + } ], "why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.", "needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김", "placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.", - "not_ready": ["FP-12-03", "FP-12-25"], - "not_ready_why": "B09 일위대가가 아직 안 섬 — 지금 세우면 절반짜리가 됨(2026-09-07 조율 창)" + "parts_note": "조각마다 **어느 성분에서 나오는지**(`from_components`)를 적는다. 이름을 바꾸면 물량이 조용히 0 이 되므로 원단위 성분 이름과 정확히 같아야 하고, 못 찾으면 드러낸다. `unit_from` 이 있으면 그 단위에서 목표 단위로 환산한다 — **철근은 ㎏ → ton (÷1000)**. ⚠ 단위를 안 맞추면 1000배 틀린다." } - ] + ], + "unit_conversion": { + "note": "조각 단위 환산. 단가의 단위(원/ton)와 원단위의 단위(㎏)가 달라 반드시 맞춰야 한다.", + "kg_to_ton": 0.001 + } }, "concrete_placing": { "note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).", @@ -180,7 +231,11 @@ }, "default_method": "ready_mixed", "default_is_provisional": true, - "structure_kinds": ["무근구조물", "철근구조물", "소형구조물"], + "structure_kinds": [ + "무근구조물", + "철근구조물", + "소형구조물" + ], "kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보.", "price_hint_krw_per_m3": { "note": "⚠ **표시 전용.** 사용자가 타설 방식을 고를 때 「정하면 얼마나 달라지는지」를 보이려고 둔 값이며 B08 의 어떤 계산에도 들어가지 않는다(금액은 B09 몫 — 8-2 경계). 값은 B09 가 2026-09-07 에 낸 철근구조물 기준 단가이고, 요율·노임이 바뀌면 어긋난다 — 화면이 「참고」임을 함께 적는다.",