feat(B08): B09 인계 두 벌 + 품셈 딱지형 표 판정 보완
일감 9. 수량 결과(작업 공종 축)와 자재 총괄(자재 축)을 각각 낸다. - 자재 줄에는 공종코드를 붙이지 않음. 붙이면 자재가 내역 줄로 오해되어 이중계상이 됨. `verify_no_code_on_materials()` 로 코드에 못 박음. - `in_bill` 을 실어 무대·합계 줄을 가름. 수량은 그대로 넘김 — 무대+도자+덤프 = 총 운반토량 검산에 쓰이는 값이라 빼면 검산이 죽음. - `ground_class_set` 동봉. 「연암」이 몇 갈래 중 하나인지 알아야 예산내역서가 섬. - 매핑표는 데이터 파일(`resources/data_work_item_mapping/`). 못 이은 줄은 빈 코드로 두지 않고 `unmatched_work_items` 로 냄. 품셈 형태 판정 교차 확인 — 「첫 칸이 갈래 딱지이고 이름이 둘째 칸」인 표에서 직종을 첫 세 행에서만 찾아 12장 구조물 표 8건이 미판정으로 빠져 있었음. 딱지를 알아보는 규칙을 맨 마지막에 두어 앞 판정을 흔들지 않고 해소 (미판정 85 → 77, 생산량형 16건 불변). 비율 지시를 먼저 보면 강관동바리 같은 소요량표가 참조로 넘어가므로 소요량을 먼저 보게 순서를 잡음. 검증 — 인계 22건 + 품셈 5건 통과, 전체 500 passed. 실서버 `GET /quantity/handoff` 200 (내역 10줄·제외 1·자재 4, 갈래세트 5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,33 @@ OCCUPATION_RE = re.compile(
|
|||||||
# 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다.
|
# 재료 소요량표의 값 단위. 직종이 없어도 이 단위가 값 열에 오면 소요량형이다.
|
||||||
MATERIAL_UNIT_MARKS = ("(kg)", "(㎏)", "(개)", "(매)", "(본)", "(ℓ)", "(L)", "(㎥)", "(㎡)", "(m)")
|
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:
|
def sha256_of(path: Path) -> str:
|
||||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
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:
|
for mark in REQUIREMENT_MARKS:
|
||||||
if mark in both:
|
if mark in both:
|
||||||
return "requirement", f"'{mark}'"
|
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", "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
return "undetermined", "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -109,6 +109,9 @@ class StructureQuantity:
|
|||||||
name: str
|
name: str
|
||||||
length_m: float = 0.0
|
length_m: float = 0.0
|
||||||
height_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)
|
components: list[Component] = field(default_factory=list)
|
||||||
notes: list[str] = 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),
|
name=(names or {}).get(type_id, type_id),
|
||||||
length_m=length,
|
length_m=length,
|
||||||
height_m=height,
|
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)
|
expander = EXPANDERS.get(type_id)
|
||||||
if expander is None:
|
if expander is None:
|
||||||
@@ -321,6 +326,8 @@ def build_table(
|
|||||||
"name": item.name,
|
"name": item.name,
|
||||||
"length_m": item.length_m,
|
"length_m": item.length_m,
|
||||||
"height_m": item.height_m,
|
"height_m": item.height_m,
|
||||||
|
"start_m": item.start_m,
|
||||||
|
"end_m": item.end_m,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ from fastapi.responses import JSONResponse
|
|||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
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_Repository import load_structures
|
||||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
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_MaterialSummary import build_table as build_material_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_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 common_util.common_util_storage import resolve_stored_project_path
|
||||||
from config.config_db import run_with_connection
|
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),
|
"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"))
|
||||||
|
|||||||
@@ -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": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"dataset_id": "data_work_item_master_manifest",
|
"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",
|
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
|
||||||
"source": {
|
"source": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
@@ -12,13 +12,13 @@
|
|||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"file": "work_item_master_2026-01-01.json",
|
"file": "work_item_master_2026-01-01.json",
|
||||||
"sha256": "ab6afec24867df51374122efb3fc10416a48b611c2b488c9e3aaa48c2035bedc",
|
"sha256": "593653135d5a2871180e7a3921f9238629b275438ef47230412aa21e9bdd80c0",
|
||||||
"size_bytes": 725962
|
"size_bytes": 725931
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"file": "form_undetermined_2026-01-01.json",
|
"file": "form_undetermined_2026-01-01.json",
|
||||||
"sha256": "e43b39bfb844f1d280fb43066ebcf09f9c129eabcbfb2190d249084994d06942",
|
"sha256": "7334dab9385bc1615a9cdb557ba814482e9a63d83b9e1232946918bfd8b5577f",
|
||||||
"size_bytes": 40578
|
"size_bytes": 37139
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1625,204 +1625,6 @@
|
|||||||
],
|
],
|
||||||
"reason": "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
"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",
|
"pum_table_id": "F0388",
|
||||||
"section": "12-34-4. 채움재(T=20m/m)",
|
"section": "12-34-4. 채움재(T=20m/m)",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"schema_version": "1.0",
|
"schema_version": "1.0",
|
||||||
"dataset_id": "work_item_master_forest",
|
"dataset_id": "work_item_master_forest",
|
||||||
"effective_date": "2026-01-01",
|
"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_version": {
|
||||||
"dataset_id": "pum_forest",
|
"dataset_id": "pum_forest",
|
||||||
"effective_date": "2026-01-01",
|
"effective_date": "2026-01-01",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"tables_total": 475,
|
"tables_total": 475,
|
||||||
"tables_attached": 456,
|
"tables_attached": 456,
|
||||||
"tables_orphan": 19,
|
"tables_orphan": 19,
|
||||||
"form_undetermined": 85
|
"form_undetermined": 77
|
||||||
},
|
},
|
||||||
"orphan_tables": [
|
"orphan_tables": [
|
||||||
{
|
{
|
||||||
@@ -33363,8 +33363,8 @@
|
|||||||
"pum_table_id": "F0367",
|
"pum_table_id": "F0367",
|
||||||
"section": "12-20. 강관동바리",
|
"section": "12-20. 강관동바리",
|
||||||
"source_line": 6630,
|
"source_line": 6630,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "requirement",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 ['인력', '자재']",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -33490,8 +33490,8 @@
|
|||||||
"pum_table_id": "F0369",
|
"pum_table_id": "F0369",
|
||||||
"section": "12-22. P.V.C 파이프 설치(50mm)",
|
"section": "12-22. P.V.C 파이프 설치(50mm)",
|
||||||
"source_line": 6654,
|
"source_line": 6654,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "reference",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -34030,8 +34030,8 @@
|
|||||||
"pum_table_id": "F0377",
|
"pum_table_id": "F0377",
|
||||||
"section": "12-27-3. 실런트(20×25mm)",
|
"section": "12-27-3. 실런트(20×25mm)",
|
||||||
"source_line": 6758,
|
"source_line": 6758,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "requirement",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 ['자재']",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -34075,8 +34075,8 @@
|
|||||||
"pum_table_id": "F0378",
|
"pum_table_id": "F0378",
|
||||||
"section": "12-28. 신구 BOX접합",
|
"section": "12-28. 신구 BOX접합",
|
||||||
"source_line": 6767,
|
"source_line": 6767,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "requirement",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 ['인력', '자재']",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -34357,8 +34357,8 @@
|
|||||||
"pum_table_id": "F0382",
|
"pum_table_id": "F0382",
|
||||||
"section": "12-31. 물끊기 홈(NOTCH) 설치",
|
"section": "12-31. 물끊기 홈(NOTCH) 설치",
|
||||||
"source_line": 6814,
|
"source_line": 6814,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "reference",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -34397,8 +34397,8 @@
|
|||||||
"pum_table_id": "F0383",
|
"pum_table_id": "F0383",
|
||||||
"section": "12-32. 전선관 설치(P.V.C ø54m/m)",
|
"section": "12-32. 전선관 설치(P.V.C ø54m/m)",
|
||||||
"source_line": 6823,
|
"source_line": 6823,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "reference",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 표의 '재료비의' — 값이 아니라 비율 지시",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
@@ -34560,8 +34560,8 @@
|
|||||||
"pum_table_id": "F0386",
|
"pum_table_id": "F0386",
|
||||||
"section": "12-34-2. 합판거푸집(3회 0~7m)",
|
"section": "12-34-2. 합판거푸집(3회 0~7m)",
|
||||||
"source_line": 6855,
|
"source_line": 6855,
|
||||||
"pum_form": "undetermined",
|
"pum_form": "reference",
|
||||||
"form_basis": "헤더·첫 행에 단위·밑수·직종 표지 없음",
|
"form_basis": "분류 딱지 표의 '회기준' — 값이 아니라 비율 지시",
|
||||||
"basis_quantity": null,
|
"basis_quantity": null,
|
||||||
"basis_unit": null,
|
"basis_unit": null,
|
||||||
"variant_key": [
|
"variant_key": [
|
||||||
|
|||||||
Reference in New Issue
Block a user