diff --git a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py index d4d3f9c8..7f880e1b 100644 --- a/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py +++ b/B08_Quantity/B08_Quantity_Build_WorkItemMaster.py @@ -83,6 +83,33 @@ OCCUPATION_RE = re.compile( # 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다. MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)") +# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (2026-09-07 서브 창이 자기 파싱에서 잡은 모양). +# `['자재', 'PVC 지수판(200×5)', 'm', '1.04']` 처럼 첫 칸이 값의 이름이 아니라 갈래다. +# 그런 표는 직종이 넷째·다섯째 행에 있어 **첫 세 행만 보는 판정에 안 걸렸고**, 12장 임도 +# 구조물 표 9건이 통째로 `undetermined` 로 빠져 있었다. 딱지를 알아보고 다시 본다. +CLASSIFICATION_TAGS = frozenset( + { + "자재", + "재료", + "재료비", + "자재비", + "잡재료", + "장비", + "기계", + "인력", + "노무", + "노무비", + "인건비", + "설치비", + "경비", + "공구손료", + } +) +# 딱지 가운데 **품이 붙는 쪽**. 이 딱지가 있으면 그 표는 소요량형이다. +RESOURCE_TAGS = frozenset({"자재", "재료", "잡재료", "장비", "기계", "인력", "노무", "공구손료"}) +# 값이 아니라 **다른 값의 몇 %** 라고만 적은 표. 품이 아니므로 참조로 둔다. +RATIO_DIRECTIVE_MARKS = ("재료비의", "주재료비의", "회기준") + def sha256_of(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -187,6 +214,24 @@ def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]: for mark in REQUIREMENT_MARKS: if mark in both: return "requirement", f"'{mark}'" + + # ⚠ 첫 칸이 분류 딱지인 표 — **맨 마지막에 본다.** 앞의 판정을 흔들지 않으려는 자리다. + tags = [norm(row[0]).replace(" ", "") for row in table.get("rows", []) if row] + if any(tag in CLASSIFICATION_TAGS for tag in tags): + # ⚠ **소요량을 먼저 본다.** 비율 지시를 먼저 보면 `잡재료비(재료비의) 5 %` 한 줄 때문에 + # 강관동바리(내관 0.38본·형틀목공 0.07인) 같은 **멀쩡한 소요량표가 참조로 넘어간다** + # — 만들다 실제로 걸린 자리다. + # 값이 실제로 있는 표만 공종으로 세운다. 이름만 있고 수치가 없으면 판정하지 않는다. + has_number = any( + re.fullmatch(r"[\d,]+(?:\.\d+)?", norm(c)) for r in table.get("rows", []) for c in r + ) + if has_number and any(tag in RESOURCE_TAGS for tag in tags): + return "requirement", f"분류 딱지 {sorted({t for t in tags if t in RESOURCE_TAGS})}" + body = " ".join(norm(c) for r in table.get("rows", []) for c in r).replace(" ", "") + for mark in RATIO_DIRECTIVE_MARKS: + if mark in body: + return "reference", f"분류 딱지 표의 '{mark}' — 값이 아니라 비율 지시" + return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음" diff --git a/B08_Quantity/B08_Quantity_Engine_Handoff.py b/B08_Quantity/B08_Quantity_Engine_Handoff.py new file mode 100644 index 00000000..9c42b7d5 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_Handoff.py @@ -0,0 +1,344 @@ +"""B08 → B09 인계 (일감 9 · PLAN 8-2 · 2026-09-07 3자 조율 확정). + +**두 벌로 낸다. 한 벌로 합치지 않는다.** + ① `work_items` = **작업 공종** 축. B09 ④예산내역서가 되는 줄이다. 공종코드가 붙는다. + ② `materials` = **자재** 축. B09 자재대가 되는 줄이다. **공종코드가 붙지 않는다.** + +⚠ 자재 줄에 공종코드를 붙이지 않는 까닭 + 내역 줄의 실체는 `돌쌓기(찰) H=1.5 · 70m` 이지 그 전개인 콘크리트 0.31㎥ 가 아니다 + (8-2 이중계상 함정). 자재에 공종코드를 붙이면 **자재가 내역 줄로 오해될 자리**가 생긴다. + 자재를 카탈로그 키에 잇는 것은 **B09 자원 축의 일**이다(8-7). + +⚠ 할증 전/후는 **자재 쪽에만** 있다 + 작업 공종에는 할증이 없다. 자재는 `net_amount`(전)·`total_amount`(후)를 둘 다 넘긴다 — + 하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. + +⚠ `in_bill` 을 반드시 싣는다 (㉡) + 무대(소운반 20m)처럼 **값은 내되 내역에 안 서는** 줄이 있다. B09 가 이 깃발을 안 보면 + ④예산내역서에 무대가 서서 운반비가 두 번 붙는다. 빼고 넘기지 않는 까닭은, + 빠진 줄과 제외된 줄을 나중에 구별할 수 없기 때문이다. + +⚠ `ground_class_set` 을 함께 싣는다 (2026-09-07 서브 이견 채택) + 값이 「연암」이어도 **그 프로젝트가 몇 갈래 세트를 쓰는지**를 알아야 ④예산내역서에서 줄을 + 세울 수 있다(울진 2 · 거창 5 · 오솔길 1). 설정 파일을 안 봐도 **인계본만으로 ④가 서게** 한다. + +⚠ 못 이은 줄은 **빈 코드로 두지 않는다** + `unmatched_work_items` 로 낸다. 빈칸이면 「코드가 없는 줄」과 「매핑을 못 찾은 줄」이 + 구별되지 않고, 조용히 없어진 줄은 아무도 못 찾는다. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping" +DATASET_PREFIX = "work_item_mapping_" + +#: 줄이 어디서 왔나 — 되짚을 때 쓴다. +ORIGIN_EARTHWORK = "earthwork" +ORIGIN_STRUCTURE = "structure" +ORIGIN_SLOPE = "slope" +ORIGIN_HAUL = "haul" + +#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다. +SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"}) + +#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다. +SUBTOTAL_GROUPS = frozenset({"보정량계"}) + + +def _latest_dataset_path(directory: Path | None = None) -> Path | None: + folder = directory or DATASET_DIR + if not folder.is_dir(): + return None + files = sorted(folder.glob(DATASET_PREFIX + "*.json")) + return files[-1] if files else None + + +@dataclass +class WorkItemMapping: + """수량 줄 → 공종 마스터 코드. 못 찾으면 `None` 을 돌려주고 부른 쪽이 목록에 남긴다.""" + + effective_date: str = "" + earthwork: list[dict[str, Any]] = field(default_factory=list) + haul: list[dict[str, Any]] = field(default_factory=list) + structure: list[dict[str, Any]] = field(default_factory=list) + pending_user: dict[str, Any] = field(default_factory=dict) + + def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: + """공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.""" + exact = [ + row + for row in self.earthwork + if row.get("group") == group and row.get("ground") == ground + ] + if exact: + return exact[0] + # 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다. + loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row] + return loose[0] if loose else None + + def for_haul(self, equipment: str) -> dict[str, Any] | None: + for row in self.haul: + if row.get("equipment") == equipment: + return row + return None + + def for_structure(self, type_id: str) -> dict[str, Any] | None: + for row in self.structure: + if row.get("type_id") == type_id: + return row + return None + + +def load_mapping(path: Path | None = None) -> WorkItemMapping: + """매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다.""" + target = path or _latest_dataset_path() + if target is None or not target.is_file(): + return WorkItemMapping() + payload = json.loads(target.read_text(encoding="utf-8")) + return WorkItemMapping( + effective_date=str(payload.get("effective_date") or ""), + earthwork=list(payload.get("earthwork") or []), + haul=list(payload.get("haul") or []), + structure=list(payload.get("structure") or []), + pending_user=payload.get("pending_user") or {}, + ) + + +def _spec_detail(structure: dict[str, Any]) -> str: + """규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다.""" + parts: list[str] = [] + height = structure.get("height_m") + length = structure.get("length_m") + if height: + parts.append(f"H={height:g}") + if length: + parts.append(f"L={length:g}m") + return "·".join(parts) + + +def _earthwork_rows( + summary_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """토공집계표 줄을 내역 줄로 옮긴다. + + ⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다. + 빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다. + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in summary_table.get("rows") or []: + group = str(row.get("group") or "") + if not group: + continue + ground = row.get("item") or None + origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK + is_subtotal = group in SUBTOTAL_GROUPS + entry = mapping.for_earthwork(group, ground) + code = (entry or {}).get("work_item_code") + if code is None and not is_subtotal: + unmatched.append(f"{group}({ground})" if ground else group) + rows.append( + { + "work_item_code": code, + "name": group, + "spec": str(row.get("spec") or ""), + "unit": str(row.get("unit") or "㎥"), + "quantity": float(row.get("amount") or 0.0), + "ground_class": ground, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": None, + "station_to": None, + "spec_detail": "", + # 합계 줄과 무대 줄은 값은 내되 내역에 안 선다. + "in_bill": bool(row.get("in_bill", True)) and not is_subtotal, + "in_bill_reason": "집계 합계 줄 — 검산용" + if is_subtotal + else str(row.get("note") or ""), + "origin": origin, + } + ) + return rows, unmatched + + +def _haul_rows( + haul_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """운반 줄 — 가중평균 거리가 붙는다. 무대는 `in_bill: False` 로 함께 넘긴다(㉡).""" + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for row in haul_table.get("rows") or []: + equipment = str(row.get("equipment") or "") + entry = mapping.for_haul(equipment) or {} + code = entry.get("work_item_code") + in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True) + if code is None and in_bill: + unmatched.append(f"운반({equipment})") + rows.append( + { + "work_item_code": code, + "name": f"{equipment} 운반", + "spec": str(row.get("ground") or ""), + "unit": "㎥", + "quantity": float(row.get("volume_m3") or 0.0), + "ground_class": row.get("ground") or None, + "haul_distance_m": float(row.get("average_distance_m") or 0.0), + "haul_equipment": equipment, + "station_from": None, + "station_to": None, + "spec_detail": "", + "in_bill": in_bill, + "in_bill_reason": str(entry.get("reason") or ""), + "origin": ORIGIN_HAUL, + } + ) + return rows, unmatched + + +def _structure_rows( + unit_quantity_table: dict[str, Any], mapping: WorkItemMapping +) -> tuple[list[dict[str, Any]], list[str]]: + """구조물 줄 — **작업 공종 하나**로 선다. + + ⚠ 전개 성분(터파기·야면석·모르터)은 여기 오지 않는다. 구조물 한 기가 내역 한 줄이고, + 그 전개는 토공 합산·자재총괄·일위대가로 갈린다(8-2 이중계상 함정). + """ + rows: list[dict[str, Any]] = [] + unmatched: list[str] = [] + for structure in unit_quantity_table.get("structures") or []: + type_id = str(structure.get("type_id") or "") + entry = mapping.for_structure(type_id) or {} + code = entry.get("work_item_code") + if code is None: + unmatched.append(f"구조물({type_id})") + length = float(structure.get("length_m") or 0.0) + rows.append( + { + "work_item_code": code, + "name": str(structure.get("name") or type_id), + "spec": _spec_detail(structure), + "unit": "m", + "quantity": length, + "ground_class": None, + "haul_distance_m": None, + "haul_equipment": None, + "station_from": structure.get("start_m"), + "station_to": structure.get("end_m"), + "spec_detail": _spec_detail(structure), + "in_bill": True, + "in_bill_reason": "", + "origin": ORIGIN_STRUCTURE, + } + ) + return rows, unmatched + + +def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]: + """자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7).""" + rows: list[dict[str, Any]] = [] + for row in material_table.get("rows") or []: + rows.append( + { + "material_name": row.get("name"), + "spec": row.get("spec") or "", + "unit": row.get("unit"), + "net_amount": row.get("net_amount"), + "total_amount": row.get("total_amount"), + "surcharge_pct": row.get("surcharge_pct"), + "surcharge_note": row.get("note") or "", + "supply_type": row.get("supply"), + "install_by": row.get("install_by"), + "source_structure": row.get("sources") or [], + } + ) + return rows + + +def build_handoff( + *, + summary_table: dict[str, Any] | None = None, + haul_table: dict[str, Any] | None = None, + unit_quantity_table: dict[str, Any] | None = None, + material_table: dict[str, Any] | None = None, + mapping: WorkItemMapping | None = None, + ground_class_set: str | None = None, + ground_classes: list[str] | None = None, +) -> dict[str, Any]: + """B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.""" + table = mapping or load_mapping() + work_items: list[dict[str, Any]] = [] + unmatched: list[str] = [] + + if summary_table: + rows, misses = _earthwork_rows(summary_table, table) + work_items.extend(rows) + unmatched.extend(misses) + if haul_table: + rows, misses = _haul_rows(haul_table, table) + work_items.extend(rows) + unmatched.extend(misses) + if unit_quantity_table: + rows, misses = _structure_rows(unit_quantity_table, table) + work_items.extend(rows) + unmatched.extend(misses) + + materials = _material_rows(material_table or {}) + return { + "work_items": work_items, + "materials": materials, + # 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다. + "ground_class_set": ground_class_set, + "ground_classes": list(ground_classes or []), + # 자재 쪽에만 할증이 있다 — 작업 공종에는 없다. + "surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")), + "unmatched_work_items": sorted(set(unmatched)), + "mapping_pending_user": table.pending_user, + "mapping_edition": table.effective_date, + "bill_row_count": sum(1 for row in work_items if row["in_bill"]), + "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), + } + + +def verify_no_code_on_materials(handoff: dict[str, Any]) -> list[str]: + """⚠ 자재 줄에 공종코드가 섞이면 알린다. + + 자재에 공종코드가 붙으면 **내역 줄로 오해될 자리**가 생기고 그게 곧 이중계상이다. + 축이 둘이라는 것은 주석이 아니라 검사로 지켜야 한다. + """ + found: list[str] = [] + for row in handoff.get("materials") or []: + if row.get("work_item_code"): + found.append(str(row.get("material_name"))) + return found + + +def verify_bill_flags(handoff: dict[str, Any]) -> list[str]: + """⚠ 코드가 없는데 내역에 서는 줄이 있으면 알린다. + + 빈 코드로 내역에 세우면 B09 가 단가를 못 붙인 채 0원 줄을 만든다. + """ + found: list[str] = [] + for row in handoff.get("work_items") or []: + if row.get("in_bill") and not row.get("work_item_code"): + found.append(str(row.get("name"))) + return found + + +def summarize(handoff: dict[str, Any]) -> dict[str, Any]: + """화면 안내용 한 줄 요약 — 넘긴 줄과 못 이은 줄을 함께 보인다.""" + return { + "bill_rows": handoff.get("bill_row_count", 0), + "excluded_rows": handoff.get("excluded_row_count", 0), + "materials": len(handoff.get("materials") or []), + "unmatched": handoff.get("unmatched_work_items") or [], + } + + +def iter_bill_rows(handoff: dict[str, Any]) -> Iterable[dict[str, Any]]: + """내역에 서는 줄만 — B09 ④예산내역서가 쓰는 입구.""" + return (row for row in handoff.get("work_items") or [] if row["in_bill"]) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index a2a11363..1325f3d9 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -109,6 +109,9 @@ class StructureQuantity: name: str length_m: float = 0.0 height_m: float = 0.0 + # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). + start_m: float | None = None + end_m: float | None = None components: list[Component] = field(default_factory=list) notes: list[str] = field(default_factory=list) @@ -267,6 +270,8 @@ def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> St name=(names or {}).get(type_id, type_id), length_m=length, 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, ) expander = EXPANDERS.get(type_id) if expander is None: @@ -321,6 +326,8 @@ def build_table( "name": item.name, "length_m": item.length_m, "height_m": item.height_m, + "start_m": item.start_m, + "end_m": item.end_m, "notes": item.notes, "components": [ { diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index eeaa6d68..c61790cb 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -25,9 +25,10 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table -from common_util.common_util_project_settings import quantity_settings +from common_util.common_util_project_settings import quantity_settings, rock_classes from common_util.common_util_storage import resolve_stored_project_path from config.config_db import run_with_connection @@ -103,3 +104,65 @@ async def get_material_summary(project_id: UUID) -> JSONResponse: "structure_count": len(structures), } ) + + +@router.get("/{project_id}/quantity/handoff") +async def get_handoff(project_id: UUID) -> JSONResponse: + """B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9). + + ⚠ **한 벌로 합치지 않는다.** 내역 줄은 작업 공종이고 자재는 자재다. + 자재에 공종코드를 붙이면 자재가 내역 줄로 오해된다(8-2 이중계상 함정). + + ⚠ 토공·운반은 토적표 라우터가 이미 만드는 표를 그대로 받는다 — 여기서 다시 계산하지 + 않는다. 같은 값을 두 벌로 짜지 않는다는 규칙(CLAUDE.md 5장)이 여기에도 걸린다. + """ + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + except Exception: + logger.exception("B08 인계 조회 실패(경로): project_id=%s", project_id) + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + structures, names, skipped = _collect_structures(project_root) + unit_table = build_unit_table(structures, names) + settings = quantity_settings(project_root) + material_table = build_material_table( + unit_table, supply_map=settings.get("material_supply") or {} + ) + + # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. + earthwork = await _earthwork_tables(project_id) + + handoff = build_handoff( + summary_table=earthwork.get("summary"), + haul_table=earthwork.get("haul"), + unit_quantity_table=unit_table, + material_table=material_table, + ground_class_set=settings.get("rock_class_set"), + ground_classes=rock_classes(settings), + ) + handoff["summary"] = summarize(handoff) + handoff["skipped_structures"] = skipped + handoff["earthwork_available"] = bool(earthwork) + return JSONResponse(content=handoff) + + +async def _earthwork_tables(project_id: UUID) -> dict[str, Any]: + """토적표 라우터가 만든 집계·운반 표를 얻는다. 노선이 없으면 빈 값.""" + from B08_Quantity.B08_Quantity_Router_Earthwork import ( + get_earthwork_table_for_current_route, + ) + + try: + response = await get_earthwork_table_for_current_route(project_id) + except Exception: + logger.exception("B08 인계 — 토적표 조회 실패: project_id=%s", project_id) + return {} + if response.status_code != 200: + return {} + import json as _json + + return _json.loads(bytes(response.body).decode("utf-8")) 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 new file mode 100644 index 00000000..646c7681 --- /dev/null +++ b/resources/data_work_item_mapping/work_item_mapping_2026-01-01.json @@ -0,0 +1,145 @@ +{ + "schema_version": "1.0", + "dataset_id": "work_item_mapping", + "effective_date": "2026-01-01", + "note": "B08 이 낸 수량 줄을 공종 마스터 코드(FP-…)에 잇는 다리. 발주처 골격이 바뀌어도 코드를 안 고치게 데이터로 둔다.", + "master": { + "dataset_id": "work_item_master", + "effective_date": "2026-01-01" + }, + "policy": { + "no_invented_codes": true, + "unmatched_is_listed": true, + "note": "못 이은 줄은 빈 코드로 두지 않고 unmatched_work_items 로 낸다. 빈칸이면 없어진 것과 구별이 안 된다." + }, + "earthwork": [ + { + "group": "흙깎기", + "ground": "토사", + "work_item_code": "FP-09-03-02", + "master_name": "토사깍기 > 기계", + "note": "인력 시공이면 FP-09-03-01" + }, + { + "group": "흙깎기", + "ground": "리핑암", + "work_item_code": "FP-09-04", + "master_name": "암절취" + }, + { + "group": "흙깎기", + "ground": "발파암", + "work_item_code": "FP-09-05", + "master_name": "발파암" + }, + { + "group": "측구터파기", + "ground": "토사", + "work_item_code": "FP-09-12-01", + "master_name": "측구터파기 > 토사" + }, + { + "group": "측구터파기", + "ground": "리핑암", + "work_item_code": "FP-09-12-02", + "master_name": "측구터파기 > 암절취" + }, + { + "group": "측구터파기", + "ground": "발파암", + "work_item_code": "FP-09-12-03", + "master_name": "측구터파기 > 발파암" + }, + { + "group": "성토", + "work_item_code": "FP-09-16", + "master_name": "노체", + "note": "포설(FP-09-16-01)·다짐(FP-09-16-02)로 갈리는 자리 — 내역 양식이 정해지면 내린다" + }, + { + "group": "성토면다짐", + "work_item_code": "FP-09-17-01", + "master_name": "다짐 > 비탈면 다짐" + }, + { + "group": "층따기", + "work_item_code": "FP-09-18", + "master_name": "층따기" + }, + { + "group": "면고르기", + "ground": "토사", + "work_item_code": "FP-09-19-01", + "master_name": "면고르기 > 토사면 고르기" + }, + { + "group": "면고르기", + "ground": "리핑암", + "work_item_code": "FP-09-19-02", + "master_name": "면고르기 > 비탈면 면고르기(암절취)" + }, + { + "group": "면고르기", + "ground": "발파암", + "work_item_code": "FP-09-19-03", + "master_name": "면고르기 > 비탈면 면고르기(발파암)" + }, + { + "group": "초류종자살포", + "work_item_code": "FP-05-24", + "master_name": "씨앗뿜어붙이기" + }, + { + "group": "되메우기", + "work_item_code": "FP-09-14-01", + "master_name": "되메우기 및 다짐 > 되메우기" + } + ], + "haul": [ + { + "equipment": "dozer", + "work_item_code": "FP-10-11", + "master_name": "불도저 운반" + }, + { + "equipment": "dump_truck", + "work_item_code": "FP-10-12", + "master_name": "덤프 운반" + }, + { + "equipment": "free_haul", + "work_item_code": null, + "in_bill": false, + "reason": "무대(소운반 20m 이내)는 품에 포함 — 내역 줄이 아니다(품셈 1-2-7, ㉡)" + } + ], + "structure": [ + { + "type_id": "masonry_wet", + "work_item_code": "FP-13-04-05", + "master_name": "돌쌓기 > 찰쌓기(장비)", + "note": "인력 시공이면 FP-13-04-04" + }, + { + "type_id": "masonry_dry", + "work_item_code": "FP-13-04-02", + "master_name": "돌쌓기 > 메쌓기(장비)", + "note": "인력 시공이면 FP-13-04-01" + } + ], + "pending_user": { + "note": "이름이 비슷한 후보는 있으나 **어느 것인지 정할 근거가 없는** 자리. 임의로 고르지 않고 unmatched 로 낸다(CLAUDE.md 3장).", + "items": [ + { + "group": "지장목제거", + "candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"], + "why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음" + }, + { + "group": "흙깎기/측구터파기 암", + "candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"], + "why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함" + } + ] + } +} diff --git a/resources/data_work_item_master/_manifest.json b/resources/data_work_item_master/_manifest.json index 738215dc..39bbea8d 100644 --- a/resources/data_work_item_master/_manifest.json +++ b/resources/data_work_item_master/_manifest.json @@ -1,7 +1,7 @@ { "schema_version": "1.0", "dataset_id": "data_work_item_master_manifest", - "generated_at": "2026-09-07T19:59:00+09:00", + "generated_at": "2026-09-08T00:00:17+09:00", "built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py", "source": { "dataset_id": "pum_forest", @@ -12,13 +12,13 @@ "files": [ { "file": "work_item_master_2026-01-01.json", - "sha256": "ab6afec24867df51374122efb3fc10416a48b611c2b488c9e3aaa48c2035bedc", - "size_bytes": 725962 + "sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0", + "size_bytes": 725931 }, { "file": "form_undetermined_2026-01-01.json", - "sha256": "e43b39bfb844f1d280fb43066ebcf09f9c129eabcbfb2190d249084994d06942", - "size_bytes": 40578 + "sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f", + "size_bytes": 37139 } ] } \ No newline at end of file diff --git a/resources/data_work_item_master/form_undetermined_2026-01-01.json b/resources/data_work_item_master/form_undetermined_2026-01-01.json index ae6868b9..9724f909 100644 --- a/resources/data_work_item_master/form_undetermined_2026-01-01.json +++ b/resources/data_work_item_master/form_undetermined_2026-01-01.json @@ -1625,204 +1625,6 @@ ], "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" }, - { - "pum_table_id": "F0367", - "section": "12-20. 강관동바리", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "강관 동바리", - "내관(48.6mm×2.4mm)", - "본", - "0.38", - "" - ], - [ - "외관(60.6mm×2.3mm)", - "본", - "0.38", - "", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0369", - "section": "12-22. P.V.C 파이프 설치(50mm)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "", - "" - ], - [ - "설치비", - "재료비의 5%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0375", - "section": "12-27-1. 지수판 설치", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "PVC 지수판(200×5)", - "m", - "1.04", - "4% 할증" - ], - [ - "용접봉", - "kg", - "0.042", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0377", - "section": "12-27-3. 실런트(20×25mm)", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "실런트", - "㎥", - "0.70", - "0.02×0.025×1400kg/㎥" - ], - [ - "인력(설치비)", - "방수공", - "인", - "0.04", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0378", - "section": "12-28. 신구 BOX접합", - "headers": [ - "구 분", - "단위", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "자재", - "에폭시 접착제", - "kg", - "1.20", - "" - ], - [ - "시너", - "ℓ", - "0.21", - "", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0382", - "section": "12-31. 물끊기 홈(NOTCH) 설치", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "", - "" - ], - [ - "설치비", - "주재료비의 5%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0383", - "section": "12-32. 전선관 설치(P.V.C ø54m/m)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "P.V.C ∅54m/m", - "" - ], - [ - "설치비", - "주재료비의 10%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, - { - "pum_table_id": "F0386", - "section": "12-34-2. 합판거푸집(3회 0~7m)", - "headers": [ - "구 분", - "적 용", - "비 고" - ], - "first_rows": [ - [ - "재료비", - "1회 기준 46.1%", - "" - ], - [ - "노무비", - "1회 기준 47.1%", - "" - ] - ], - "reason": "헤더·첫 행에 단위·밑수·직종 표지 없음" - }, { "pum_table_id": "F0388", "section": "12-34-4. 채움재(T=20m/m)", diff --git a/resources/data_work_item_master/work_item_master_2026-01-01.json b/resources/data_work_item_master/work_item_master_2026-01-01.json index 8bd4105f..724c8a4e 100644 --- a/resources/data_work_item_master/work_item_master_2026-01-01.json +++ b/resources/data_work_item_master/work_item_master_2026-01-01.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "work_item_master_forest", "effective_date": "2026-01-01", - "generated_at": "2026-09-07T19:59:00+09:00", + "generated_at": "2026-09-08T00:00:17+09:00", "dataset_version": { "dataset_id": "pum_forest", "effective_date": "2026-01-01", @@ -20,7 +20,7 @@ "tables_total": 475, "tables_attached": 456, "tables_orphan": 19, - "form_undetermined": 85 + "form_undetermined": 77 }, "orphan_tables": [ { @@ -33363,8 +33363,8 @@ "pum_table_id": "F0367", "section": "12-20. 강관동바리", "source_line": 6630, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -33490,8 +33490,8 @@ "pum_table_id": "F0369", "section": "12-22. P.V.C 파이프 설치(50mm)", "source_line": 6654, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34030,8 +34030,8 @@ "pum_table_id": "F0377", "section": "12-27-3. 실런트(20×25mm)", "source_line": 6758, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34075,8 +34075,8 @@ "pum_table_id": "F0378", "section": "12-28. 신구 BOX접합", "source_line": 6767, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "requirement", + "form_basis": "분류 딱지 ['인력', '자재']", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34357,8 +34357,8 @@ "pum_table_id": "F0382", "section": "12-31. 물끊기 홈(NOTCH) 설치", "source_line": 6814, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34397,8 +34397,8 @@ "pum_table_id": "F0383", "section": "12-32. 전선관 설치(P.V.C ø54m/m)", "source_line": 6823, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [ @@ -34560,8 +34560,8 @@ "pum_table_id": "F0386", "section": "12-34-2. 합판거푸집(3회 0~7m)", "source_line": 6855, - "pum_form": "undetermined", - "form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음", + "pum_form": "reference", + "form_basis": "분류 딱지 표의 '회기준' — 값이 아니라 비율 지시", "basis_quantity": null, "basis_unit": null, "variant_key": [