merge: origin/main_desktop_1 되받기 (B08 공종 마스터 재생성 ab39c174)
로케일 충돌 1건 해소 — B08 구역 줄바꿈 차이뿐, 상대 쪽 서식 채택 (내 B09 키는 자동 병합됨). 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)")
|
||||
|
||||
# ⚠ **첫 칸이 분류 딱지이고 이름이 둘째 칸인 표** (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", "헤더·첫 행에 단위·밑수·직종 표지 없음"
|
||||
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -0,0 +1,306 @@
|
||||
"""자재 총괄표 — 할증이 붙는 **유일한** 자리 (B08 일감 7 · PLAN 8-2·8-3·8-7).
|
||||
|
||||
여기가 하는 일은 하나다
|
||||
구조물 전개(`..._Engine_UnitQuantity`)가 낸 성분 가운데 **`destination == "material"`**
|
||||
인 것만 모아, 자재별로 합치고 **할증률을 한 번** 붙인다. 열은 순수량·할증률·합계 셋이고
|
||||
**금액은 없다**(금액은 B09 몫, 8-2 경계).
|
||||
|
||||
⚠⚠ ㉠ 이중계상 방어 — 할증은 여기 한 번뿐이다
|
||||
원단위표(`surcharge_applied: False`)도 B09 일위대가 재료비도 **할증 전** 값이다.
|
||||
두 곳에서 붙이면 자재가 두 번 부푼다. `verify_single_surcharge()` 가 입력 표의
|
||||
깃발을 실제로 읽어 막는다 — 규칙이 주석에만 있으면 지켜지지 않는다.
|
||||
|
||||
⚠ `earthwork`·`unit_price` 는 여기 오지 않는다
|
||||
터파기·되메우기는 토공집계로, 모르터·돌쌓기는 B09 일위대가로 간다. 섞이면 그게 곧
|
||||
이중계상이다. 걸러 낸 성분은 버리지 않고 `skipped_by_destination` 으로 세어 보인다.
|
||||
|
||||
⚠ 할증률을 코드에 박지 않는다 (요율과 같은 취급)
|
||||
값은 `resources/data_material_surcharge/material_surcharge_<판>.json` 에 있다.
|
||||
표에 없는 자재는 **0 % 로 조용히 넘기지 않는다** — 「할증률 미확보」로 드러낸다.
|
||||
0 % 로 넘기면 빠뜨린 것과 구별이 안 된다.
|
||||
|
||||
⚠ 품셈에 이미 할증이 포함된 항목은 제외한다 (품셈 1-3-1 단서)
|
||||
「품셈 항목에 할증이 포함ㆍ표시된 경우 중복 적용 금지」. 성분이 그렇게 표시돼 오면
|
||||
(`surcharge_included: True`) 율을 붙이지 않고 비고에 까닭을 남긴다.
|
||||
|
||||
⚠ 관급/사급은 **법이 아니라 발주 결정**이다
|
||||
자재마다 정해진 값이 아니므로 지어내지 않는다. 프로젝트 설정
|
||||
(`quantity.material_supply`)이 정한 것만 따르고, 안 정한 자재는 `unknown` 으로 남겨
|
||||
화면에 드러낸다. 구분 이름은 B09 와 같은 낱말을 쓴다 — 다르면 인계에서 어긋난다.
|
||||
관급 줄에는 **설치 주체**(`install_by`)가 하나 더 붙는다 — 안전관리비 대상액이
|
||||
관급 전액이 아니라 「도급자설치 관급금액」이기 때문이다.
|
||||
|
||||
⚠ 자재 이름은 **정확히 일치**로만 찾는다
|
||||
부분일치로 재면 `막자갈`(뒤채움)이 `자갈` 할증을 물게 된다 — 원단위 엔진에서 이미
|
||||
한 번 겪은 자리다. 못 찾으면 지어내지 말고 「할증률 미확보」로 드러낸다.
|
||||
|
||||
⚠ 할증 전/후 값을 **둘 다** 남긴다 (8-2 인계 6필드)
|
||||
하나만 넘기면 B09 가 어느 쪽인지 몰라 역산한다. `net_amount`(전) 와
|
||||
`total_amount`(후) 를 나란히 둔다.
|
||||
"""
|
||||
|
||||
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_material_surcharge"
|
||||
DATASET_PREFIX = "material_surcharge_"
|
||||
|
||||
#: 이 표가 받는 성분 갈래. 나머지는 각자 다른 표로 간다.
|
||||
ACCEPTED_DESTINATION = "material"
|
||||
|
||||
#: 관급/사급 구분 — **데이터 값은 영문 키, 한글은 화면 표기용**(2026-09-07 B09 와 확정).
|
||||
#: B09 원가 엔진의 `owner_supplied_material_krw`(⑤ 관급자재대)와 같은 낱말이라 그대로 이어진다.
|
||||
SUPPLY_OWNER = "owner_supplied" # 관급 — 발주처 지급
|
||||
SUPPLY_CONTRACTOR = "contractor_supplied" # 사급 — 도급자 구입
|
||||
SUPPLY_UNKNOWN = "unknown" # 아직 안 정함 — 화면에 드러낸다
|
||||
SUPPLY_LABELS = {SUPPLY_OWNER: "관급", SUPPLY_CONTRACTOR: "사급", SUPPLY_UNKNOWN: "미분류"}
|
||||
|
||||
#: ⚠ 관급 안의 **설치 주체** — 안전관리비 대상액은 관급 전액이 아니라 「도급자설치 관급금액」이다
|
||||
#: (PLAN 8-10 대상액 정의). 관급/사급 두 갈래로만 두면 B09 가 ⑤를 못 세운다.
|
||||
#: **관급 줄에만 붙이고 사급 줄은 비운다.** 모르면 기본값으로 때우지 않고 `None` 으로 둔다 —
|
||||
#: 잘못 찍으면 안전관리비가 조용히 틀린다.
|
||||
INSTALL_BY_CONTRACTOR = "contractor" # 도급자설치
|
||||
INSTALL_BY_OWNER = "owner" # 관 직접설치
|
||||
INSTALL_BY_LABELS = {INSTALL_BY_CONTRACTOR: "도급자설치", INSTALL_BY_OWNER: "관 직접설치"}
|
||||
NOTE_INSTALL_BY_MISSING = "설치 주체 미지정"
|
||||
|
||||
NOTE_RATE_MISSING = "할증률 미확보"
|
||||
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
|
||||
|
||||
|
||||
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 SurchargeTable:
|
||||
"""할증률표 한 판. 조건이 갈리는 자재는 `alt_rate` 를 같이 들고 있는다."""
|
||||
|
||||
effective_date: str = ""
|
||||
source: dict[str, Any] = field(default_factory=dict)
|
||||
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
|
||||
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
|
||||
entry = self.rates.get(material.strip())
|
||||
if entry is None:
|
||||
return None, ""
|
||||
alt_condition = entry.get("alt_condition")
|
||||
if condition and alt_condition and condition == alt_condition:
|
||||
return float(entry["alt_rate"]), material + "(" + str(alt_condition) + ")"
|
||||
base_condition = entry.get("condition")
|
||||
label = material + "(" + str(base_condition) + ")" if base_condition else material
|
||||
return float(entry["rate"]), label
|
||||
|
||||
@property
|
||||
def material_names(self) -> list[str]:
|
||||
return sorted(self.rates)
|
||||
|
||||
|
||||
def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
|
||||
"""할증률표를 읽는다. 파일이 없으면 **빈 표** — 전 자재가 「미확보」로 드러난다."""
|
||||
target = path or _latest_dataset_path()
|
||||
if target is None or not target.is_file():
|
||||
return SurchargeTable()
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
rates = {
|
||||
str(row["material"]).strip(): row
|
||||
for row in payload.get("rates_pct", [])
|
||||
if row.get("material") is not None and row.get("rate") is not None
|
||||
}
|
||||
return SurchargeTable(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
source=payload.get("source") or {},
|
||||
rates=rates,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaterialRow:
|
||||
"""총괄표 한 줄. 할증 **전·후를 둘 다** 들고 있는다(8-2 인계)."""
|
||||
|
||||
name: str
|
||||
unit: str
|
||||
net_amount: float = 0.0 # 순수량 — 할증 전
|
||||
surcharge_pct: float | None = None # None = 미확보
|
||||
supply: str = SUPPLY_UNKNOWN
|
||||
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
|
||||
surcharge_included: bool = False # 품셈에 이미 포함
|
||||
basis: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
"""합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다."""
|
||||
if self.surcharge_included or self.surcharge_pct is None:
|
||||
return self.net_amount
|
||||
return self.net_amount * (1.0 + self.surcharge_pct / 100.0)
|
||||
|
||||
@property
|
||||
def note(self) -> str:
|
||||
parts: list[str] = []
|
||||
if self.surcharge_included:
|
||||
parts.append(NOTE_INCLUDED)
|
||||
elif self.surcharge_pct is None:
|
||||
parts.append(NOTE_RATE_MISSING)
|
||||
elif self.basis:
|
||||
parts.append(self.basis)
|
||||
if self.supply == SUPPLY_OWNER and self.install_by is None:
|
||||
parts.append(NOTE_INSTALL_BY_MISSING)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _supply_of(value: Any) -> tuple[str, str | None]:
|
||||
"""설정 한 칸을 (관급구분, 설치주체) 로 읽는다.
|
||||
|
||||
설정은 두 모양을 받는다 — 구분만 적은 `"owner_supplied"` 와 설치 주체까지 적은
|
||||
`{"supply": ..., "install_by": ...}`. 앞 모양으로 적힌 관급은 **설치 주체 미지정**이 되고
|
||||
그대로 드러난다. 기본값으로 때우지 않는다 — 잘못 찍으면 안전관리비가 조용히 틀린다.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
supply = str(value.get("supply") or SUPPLY_UNKNOWN)
|
||||
install_by = value.get("install_by")
|
||||
install_by = str(install_by) if install_by else None
|
||||
else:
|
||||
supply = str(value) if value else SUPPLY_UNKNOWN
|
||||
install_by = None
|
||||
if supply != SUPPLY_OWNER:
|
||||
install_by = None # 사급 줄은 비워 둔다
|
||||
return supply, install_by
|
||||
|
||||
|
||||
def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[str]:
|
||||
"""⚠ 앞 단계가 이미 할증을 붙였으면 알린다 (㉠ 방어).
|
||||
|
||||
원단위표는 `surcharge_applied: False` 로 「할증 전」임을 못 박아 보낸다. 그 깃발이
|
||||
참이면 여기서 또 붙일 수 없다 — **조용히 건너뛰지 않고 알린다**. 말없이 넘기면
|
||||
어느 쪽이 적용됐는지 아무도 모른다.
|
||||
"""
|
||||
if not unit_quantity_table:
|
||||
return []
|
||||
if unit_quantity_table.get("surcharge_applied"):
|
||||
return ["앞 단계(구조물 원단위)가 이미 할증을 붙였음 — 자재총괄에서 중복 적용 위험"]
|
||||
return []
|
||||
|
||||
|
||||
def _collect(
|
||||
unit_quantity_table: dict[str, Any],
|
||||
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
|
||||
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
|
||||
rows: dict[tuple[str, str], MaterialRow] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
for structure in unit_quantity_table.get("structures", []):
|
||||
label = str(structure.get("name") or structure.get("type_id") or "")
|
||||
for component in structure.get("components", []):
|
||||
destination = str(component.get("destination") or "") or "(없음)"
|
||||
if destination != ACCEPTED_DESTINATION:
|
||||
skipped[destination] = skipped.get(destination, 0) + 1
|
||||
continue
|
||||
name = str(component.get("name") or "").strip()
|
||||
unit = str(component.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(component.get("amount") or 0.0)
|
||||
if component.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
if label and label not in row.sources:
|
||||
row.sources.append(label)
|
||||
return rows, skipped
|
||||
|
||||
|
||||
def build_table(
|
||||
unit_quantity_table: dict[str, Any],
|
||||
*,
|
||||
surcharge_table: SurchargeTable | None = None,
|
||||
supply_map: dict[str, Any] | None = None,
|
||||
extra_materials: Iterable[dict[str, Any]] = (),
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양.
|
||||
|
||||
`extra_materials` 는 구조물 전개 밖에서 오는 자재(떼·초류종자 등 사면 계열)를 받는 자리다.
|
||||
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
|
||||
"""
|
||||
table = surcharge_table or load_surcharge_table()
|
||||
rows, skipped = _collect(unit_quantity_table)
|
||||
|
||||
for item in extra_materials:
|
||||
if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION:
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
unit = str(item.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(item.get("amount") or 0.0)
|
||||
if item.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
source = str(item.get("source") or "")
|
||||
if source and source not in row.sources:
|
||||
row.sources.append(source)
|
||||
|
||||
supply = supply_map or {}
|
||||
missing_rate: list[str] = []
|
||||
missing_supply: list[str] = []
|
||||
missing_install_by: list[str] = []
|
||||
for (name, _unit), row in rows.items():
|
||||
row.supply, row.install_by = _supply_of(supply.get(name))
|
||||
if row.supply == SUPPLY_UNKNOWN:
|
||||
missing_supply.append(name)
|
||||
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
|
||||
if row.supply == SUPPLY_OWNER and row.install_by is None:
|
||||
missing_install_by.append(name)
|
||||
if row.surcharge_included:
|
||||
continue
|
||||
rate, basis = table.rate_for(name)
|
||||
row.surcharge_pct = rate
|
||||
row.basis = basis
|
||||
if rate is None:
|
||||
missing_rate.append(name)
|
||||
|
||||
ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit))
|
||||
return {
|
||||
"columns": [
|
||||
"자재명",
|
||||
"단위",
|
||||
"순수량",
|
||||
"할증률(%)",
|
||||
"합계",
|
||||
"관급구분",
|
||||
"설치주체",
|
||||
"비고",
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"name": row.name,
|
||||
"unit": row.unit,
|
||||
"net_amount": row.net_amount,
|
||||
"surcharge_pct": row.surcharge_pct,
|
||||
"total_amount": row.total_amount,
|
||||
"supply": row.supply,
|
||||
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
|
||||
"install_by": row.install_by,
|
||||
"install_by_label": INSTALL_BY_LABELS.get(row.install_by or "", ""),
|
||||
"note": row.note,
|
||||
"sources": row.sources,
|
||||
}
|
||||
for row in ordered
|
||||
],
|
||||
# 이 표가 할증을 붙인 곳임을 못 박는다 — B09 는 다시 붙이지 않는다(㉠).
|
||||
"surcharge_applied": True,
|
||||
"surcharge_dataset": {
|
||||
"effective_date": table.effective_date,
|
||||
"source": table.source,
|
||||
},
|
||||
"missing_rate_materials": sorted(set(missing_rate)),
|
||||
"missing_supply_materials": sorted(set(missing_supply)),
|
||||
"missing_install_by_materials": sorted(set(missing_install_by)),
|
||||
"double_count_warnings": verify_single_surcharge(unit_quantity_table),
|
||||
"skipped_by_destination": skipped,
|
||||
"row_count": len(ordered),
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"""구조물 원단위 전개식 — 치수에서 성분 물량을 낸다 (B08 일감 6 · PLAN 8-6·8-8·8-15).
|
||||
|
||||
식을 발명하지 않는다 — 실무 원본을 옮긴다
|
||||
울진 설계원본 `5. 구조도(기번3).xlsx` 에 구조물 31종의 계산식이 **살아 있는 수식**으로
|
||||
남아 있다(PLAN 8-15). 여기 옮긴 것은 그 식이며, 식 안에 상수로 박혀 있던 값
|
||||
(돌 뒷길이 0.45 · 공극률 0.77 · 돌 비중 2.65 · 고임돌 0.15 등)은 **계수표로 뺐다**.
|
||||
그래야 뒷길이가 바뀔 때 식을 안 고친다 — 실무 방식의 약점을 여기서 고친다.
|
||||
|
||||
치수 정본은 하나다 (PLAN 8-6 ② 필수 조건)
|
||||
전개식은 **저장된 구조물 제원**(`structures.json` 의 `type_id`·`options`)을 읽어 계산한다.
|
||||
자기 치수표를 따로 들지 않는다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다.
|
||||
|
||||
⚠⚠ 이중계상 셋 — 이 파일이 지켜야 할 규칙
|
||||
㉢ **배합을 분해하지 않는다.** 산출물은 `콘크리트 ㎥` · `모르터 ㎥` 에서 **멈춘다**.
|
||||
시멘트·모래·자갈로 쪼개는 것은 B09 일위대가 몫이다. 양쪽이 쪼개면 시멘트가 두 배가 된다.
|
||||
실무 원단위 라이브러리에 배합이 이미 분해돼 있어도 **그 줄은 버린다**(PLAN 8-8 ②).
|
||||
`verify_no_mix_components()` 가 이 규칙을 코드로 지킨다.
|
||||
㉠ **할증을 붙이지 않는다.** 여기 값은 전부 할증 **전**이다. 할증은 자재총괄 한 곳뿐(PLAN 8-7).
|
||||
· **터파기·되메우기·잔토는 토공으로 합산된다.** 내역 줄의 실체는 작업 공종
|
||||
(`돌쌓기(찰) H=1.5 · 70m`)이고 그 전개인 터파기는 토공 대분류로 합쳐진다
|
||||
(울진 토적집계 D12~D14 실증). 둘 다 내역에 올리면 이중계상이다 —
|
||||
그래서 성분마다 `destination` 을 달아 어디로 갈 값인지 표시한다.
|
||||
|
||||
⚠ 공제 규칙 (품셈 1-2-1 원문)
|
||||
「말뚝머리, 볼트 구멍, 모따기ㆍ물구멍, 이음줄눈 간격, 포장 1개소당 0.1 ㎡ 이하 구조물 자리,
|
||||
리벳 구멍, **철근콘크리트 중의 철근** 등」은 **공제하지 않는다.**
|
||||
치수를 곧이곧대로 빼면 실무값과 어긋난다. 전개식에서 빼는 것은 **관 통과 단면**처럼
|
||||
실제로 비어 있는 자리뿐이다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable
|
||||
|
||||
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
|
||||
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
|
||||
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
|
||||
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
|
||||
35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575},
|
||||
45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88},
|
||||
55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10},
|
||||
60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None},
|
||||
}
|
||||
DEFAULT_BACK_LENGTH_CM = 45
|
||||
|
||||
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
|
||||
STONE_MASONRY = {
|
||||
"face_to_slope_factor": 1.04, # 돌쌓기 면적 = 정면적 × 1.04 (비탈 기울기 몫)
|
||||
"thickness_base_m": 0.45, # 평균두께 식의 밑돌 두께
|
||||
"thickness_top_coeff": 0.10, # 상부 두께 계수 (0.45 + 0.10·H)
|
||||
"thickness_bottom_coeff": 0.40, # 하부 두께 계수 (0.45 + 0.40·H)
|
||||
# ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다.
|
||||
# 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다.
|
||||
"weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적
|
||||
"weep_hole_length_m": 0.5, # 물구멍 1개소당 관 길이
|
||||
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
|
||||
"excavation_extra_m": 0.2, # 터파기 폭 여유
|
||||
"backfill_thickness_m": 0.2, # 되메우기 두께
|
||||
}
|
||||
|
||||
# 성분이 어디로 가는가 — 이중계상을 막는 표시.
|
||||
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
|
||||
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
|
||||
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
|
||||
DESTINATION = {
|
||||
"터파기": "earthwork",
|
||||
"되메우기": "earthwork",
|
||||
"잔토처리": "earthwork",
|
||||
"돌쌓기": "unit_price",
|
||||
"돌붙임": "unit_price",
|
||||
"깬돌": "material",
|
||||
"야면석": "material",
|
||||
"고임돌": "material",
|
||||
"막자갈": "material",
|
||||
"콘크리트": "unit_price",
|
||||
"채움콘크리트": "unit_price",
|
||||
"모르터": "unit_price",
|
||||
"거푸집": "unit_price",
|
||||
"물구멍": "material",
|
||||
}
|
||||
|
||||
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
|
||||
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
|
||||
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
|
||||
MIX_COMPONENTS = frozenset(
|
||||
{"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Component:
|
||||
"""전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다."""
|
||||
|
||||
name: str
|
||||
unit: str
|
||||
amount: float
|
||||
destination: str
|
||||
basis: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class StructureQuantity:
|
||||
"""구조물 하나의 원단위 전개 결과."""
|
||||
|
||||
structure_id: str | None
|
||||
type_id: str
|
||||
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)
|
||||
|
||||
|
||||
def _back_length(options: dict[str, Any]) -> int:
|
||||
raw = options.get("stone_back_length_cm")
|
||||
if isinstance(raw, (int, float)) and int(raw) in STONE_BACK_LENGTH_TABLE:
|
||||
return int(raw)
|
||||
return DEFAULT_BACK_LENGTH_CM
|
||||
|
||||
|
||||
def _num(value: Any, fallback: float = 0.0) -> float:
|
||||
return float(value) if isinstance(value, (int, float)) else fallback
|
||||
|
||||
|
||||
def stone_masonry(
|
||||
height_m: float, length_m: float, options: dict[str, Any], wet: bool
|
||||
) -> tuple[list[Component], list[str]]:
|
||||
"""돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식.
|
||||
|
||||
실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3):
|
||||
정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245
|
||||
터파기 1.55 · 되메우기 0.30 · 잔토 1.25
|
||||
"""
|
||||
notes: list[str] = []
|
||||
if height_m <= 0 or length_m <= 0:
|
||||
return [], ["높이·연장이 없어 전개하지 않음"]
|
||||
|
||||
back_cm = _back_length(options)
|
||||
table = STONE_BACK_LENGTH_TABLE[back_cm]
|
||||
slope_ratio = _num(options.get("face_slope_ratio"), 0.3) # 전면 기울기 1:0.3 (교본 7-3)
|
||||
constants = STONE_MASONRY
|
||||
|
||||
face_area = height_m * length_m # 정면적
|
||||
# 비탈면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다.
|
||||
slope_area = face_area * math.hypot(1.0, slope_ratio)
|
||||
masonry_area = slope_area * constants["face_to_slope_factor"]
|
||||
thickness = (
|
||||
(constants["thickness_base_m"] + constants["thickness_top_coeff"] * height_m)
|
||||
+ (constants["thickness_base_m"] + constants["thickness_bottom_coeff"] * height_m)
|
||||
) / 2.0
|
||||
volume = face_area * thickness # 입적
|
||||
|
||||
components = [
|
||||
Component("돌쌓기", "㎡", masonry_area, DESTINATION["돌쌓기"], "비탈면적 × 1.04"),
|
||||
Component(
|
||||
"고임돌",
|
||||
"㎥",
|
||||
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
|
||||
DESTINATION["고임돌"],
|
||||
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
|
||||
),
|
||||
]
|
||||
|
||||
stone_ton = table["stone_ton_per_m2"]
|
||||
if stone_ton is None:
|
||||
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
|
||||
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함")
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
"야면석",
|
||||
"ton",
|
||||
masonry_area * stone_ton,
|
||||
DESTINATION["야면석"],
|
||||
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)",
|
||||
)
|
||||
)
|
||||
|
||||
# 막자갈 = 입적 − (면적 × 뒷길이 × 2/3 + 고임돌). 실무 식 그대로.
|
||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"])
|
||||
rubble = volume - (masonry_area * (back_cm / 100.0) * 2.0 / 3.0 + wedge)
|
||||
if rubble > 0:
|
||||
components.append(
|
||||
Component(
|
||||
"막자갈", "㎥", rubble, DESTINATION["막자갈"], "입적 − (면적×뒷길이×2/3 + 고임돌)"
|
||||
)
|
||||
)
|
||||
|
||||
if wet:
|
||||
components.append(
|
||||
Component(
|
||||
"채움콘크리트",
|
||||
"㎥",
|
||||
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
|
||||
DESTINATION["채움콘크리트"],
|
||||
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)",
|
||||
)
|
||||
)
|
||||
components.append(
|
||||
Component(
|
||||
"모르터",
|
||||
"㎥",
|
||||
masonry_area * constants["mortar_m3_per_m2"],
|
||||
DESTINATION["모르터"],
|
||||
f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)",
|
||||
)
|
||||
)
|
||||
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
|
||||
|
||||
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m.
|
||||
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
|
||||
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
|
||||
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
|
||||
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
|
||||
components.append(
|
||||
Component(
|
||||
"물구멍",
|
||||
"m",
|
||||
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
|
||||
DESTINATION["물구멍"],
|
||||
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 (관 규격 미확정)",
|
||||
)
|
||||
)
|
||||
|
||||
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
|
||||
excavation = height_m * (thickness + constants["excavation_extra_m"]) * length_m
|
||||
backfill = height_m * constants["backfill_thickness_m"] * length_m
|
||||
components.extend(
|
||||
[
|
||||
Component(
|
||||
"터파기", "㎥", excavation, DESTINATION["터파기"], "높이 × (평균두께+0.2) × 연장"
|
||||
),
|
||||
Component("되메우기", "㎥", backfill, DESTINATION["되메우기"], "높이 × 0.2 × 연장"),
|
||||
Component(
|
||||
"잔토처리",
|
||||
"㎥",
|
||||
excavation - backfill,
|
||||
DESTINATION["잔토처리"],
|
||||
"터파기 − 되메우기",
|
||||
),
|
||||
]
|
||||
)
|
||||
return components, notes
|
||||
|
||||
|
||||
# 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다).
|
||||
EXPANDERS = {
|
||||
"masonry_wet": lambda h, l, o: stone_masonry(h, l, o, wet=True),
|
||||
"masonry_dry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
|
||||
"boulder_masonry": lambda h, l, o: stone_masonry(h, l, o, wet=False),
|
||||
}
|
||||
|
||||
|
||||
def expand(structure: dict[str, Any], names: dict[str, str] | None = None) -> StructureQuantity:
|
||||
"""구조물 하나를 전개한다. 치수는 저장된 제원에서만 읽는다(치수 두 벌 금지)."""
|
||||
type_id = str(structure.get("type_id") or "")
|
||||
options = structure.get("options") or {}
|
||||
start = _num(structure.get("start_m"))
|
||||
end = _num(structure.get("end_m"))
|
||||
length = _num(options.get("length_m")) or abs(end - start)
|
||||
height = _num(options.get("height_m"))
|
||||
result = StructureQuantity(
|
||||
structure_id=structure.get("structure_id"),
|
||||
type_id=type_id,
|
||||
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:
|
||||
result.notes.append(f"'{type_id}' 전개식이 아직 없음 — 물량을 내지 않음")
|
||||
return result
|
||||
result.components, notes = expander(height, length, options)
|
||||
result.notes.extend(notes)
|
||||
return result
|
||||
|
||||
|
||||
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
|
||||
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
|
||||
|
||||
시멘트·모래·자갈은 **B09 일위대가**가 배합표로 분해할 값이다. 여기서 내면 두 배가 된다.
|
||||
실무 원단위 라이브러리를 베끼다 딸려 들어오기 쉬운 자리라 코드로 막는다.
|
||||
"""
|
||||
found: list[str] = []
|
||||
for item in quantities:
|
||||
for component in item.components:
|
||||
if component.name.strip() in MIX_COMPONENTS:
|
||||
found.append(f"{item.name}({item.type_id}) 의 '{component.name}'")
|
||||
return found
|
||||
|
||||
|
||||
def build_table(
|
||||
structures: Iterable[dict[str, Any]], names: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
|
||||
quantities = [expand(item, names) for item in structures]
|
||||
violations = verify_no_mix_components(quantities)
|
||||
|
||||
totals: dict[str, dict[str, Any]] = {}
|
||||
for item in quantities:
|
||||
for component in item.components:
|
||||
key = f"{component.name}|{component.unit}"
|
||||
entry = totals.setdefault(
|
||||
key,
|
||||
{
|
||||
"name": component.name,
|
||||
"unit": component.unit,
|
||||
"amount": 0.0,
|
||||
"destination": component.destination,
|
||||
},
|
||||
)
|
||||
entry["amount"] += component.amount
|
||||
|
||||
return {
|
||||
"structures": [
|
||||
{
|
||||
"structure_id": item.structure_id,
|
||||
"type_id": item.type_id,
|
||||
"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": [
|
||||
{
|
||||
"name": component.name,
|
||||
"unit": component.unit,
|
||||
"amount": component.amount,
|
||||
"destination": component.destination,
|
||||
"basis": component.basis,
|
||||
}
|
||||
for component in item.components
|
||||
],
|
||||
}
|
||||
for item in quantities
|
||||
],
|
||||
"totals": sorted(totals.values(), key=lambda entry: entry["name"]),
|
||||
# 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠).
|
||||
"surcharge_applied": False,
|
||||
"mix_components_found": violations,
|
||||
"structure_count": len(quantities),
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""B08 구조물 원단위·자재총괄 조회 라우터 (일감 6·7 · PLAN 8-2·8-6·8-7).
|
||||
|
||||
값은 어디서 오나
|
||||
치수 정본은 **`structures.json` 하나**다(B05 가 주인). B08 은 자기 치수표를 들지 않고
|
||||
그 제원을 읽어 전개할 뿐이다 — 도면은 H=1.5 인데 수량은 옛 치수로 도는 사고를 막는다.
|
||||
|
||||
⚠ `design_owner` 가 붙은 타입은 건너뛴다
|
||||
측구가 그렇다 — 횡단 설계가 이미 터파기 단면적까지 셈하므로 구조물로 또 세면 **같은 것을
|
||||
두 번 계상**한다(레지스트리 주석, 2026-09-07 조사). 건너뛴 것은 숨기지 않고 응답에 적는다.
|
||||
|
||||
⚠ 할증은 자재총괄 한 곳뿐이다 (㉠)
|
||||
원단위표는 할증 **전** 값(`surcharge_applied: False`)으로 오고, 자재총괄이 한 번 붙인다.
|
||||
응답에 두 깃발이 다 실리므로 화면·B09 가 어느 쪽 값인지 헷갈릴 일이 없다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
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, rock_classes
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
||||
|
||||
|
||||
def _collect_structures(
|
||||
project_root: str,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
|
||||
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다."""
|
||||
_revision, items = load_structures(project_root)
|
||||
types = structure_type_map()
|
||||
targets: list[dict[str, Any]] = []
|
||||
names: dict[str, str] = {}
|
||||
skipped: list[str] = []
|
||||
for item in items:
|
||||
payload = item.model_dump()
|
||||
type_id = str(payload.get("type_id") or "")
|
||||
definition = types.get(type_id)
|
||||
if definition is None:
|
||||
skipped.append(f"{type_id}: 레지스트리에 없는 타입")
|
||||
continue
|
||||
names[type_id] = definition.name
|
||||
if definition.design_owner:
|
||||
skipped.append(
|
||||
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
|
||||
)
|
||||
continue
|
||||
if definition.reference_only:
|
||||
skipped.append(f"{definition.name}: 전문 상세설계 대상 — 배치까지만")
|
||||
continue
|
||||
targets.append(payload)
|
||||
return targets, names, sorted(set(skipped))
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/material-summary")
|
||||
async def get_material_summary(project_id: UUID) -> JSONResponse:
|
||||
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
|
||||
|
||||
자재총괄은 원단위의 `material` 성분만 모은 것이라 따로 부르면 같은 전개를 두 번 돈다.
|
||||
"""
|
||||
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": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
|
||||
try:
|
||||
structures, names, skipped = _collect_structures(project_root)
|
||||
except Exception:
|
||||
logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
|
||||
)
|
||||
|
||||
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 {},
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"unit_quantity": unit_table,
|
||||
"material": material_table,
|
||||
"skipped_structures": skipped,
|
||||
"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,232 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_MaterialGrid.ts
|
||||
* 자재총괄표·구조물 원단위 그리드 (PLAN 8-2·8-6·8-7).
|
||||
*
|
||||
* 자재총괄 열은 순수량·할증률·합계 셋이고 **금액이 없다** — 금액은 B09 몫이다.
|
||||
*
|
||||
* ⚠ 「모르는 값」을 빈칸으로 두지 않는다. 할증률 미확보·관급구분 미분류·설치주체 미지정은
|
||||
* 모두 화면에 **글자로** 뜬다. 0 % 나 빈칸으로 두면 「할증 없음」과 구별이 안 되고,
|
||||
* 설치 주체를 못 정한 채 넘어가면 B09 안전관리비가 조용히 틀린다.
|
||||
*
|
||||
* ⚠ 반올림은 여기서만 한다(PLAN 8-16 표기 자리 ≠ 계산 자리). 서버가 준 값은 전정밀이다.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface MaterialRow {
|
||||
name: string;
|
||||
unit: string;
|
||||
net_amount: number;
|
||||
surcharge_pct: number | null;
|
||||
total_amount: number;
|
||||
supply: string;
|
||||
supply_label: string;
|
||||
install_by: string | null;
|
||||
install_by_label: string;
|
||||
note: string;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface MaterialTable {
|
||||
columns: string[];
|
||||
rows: MaterialRow[];
|
||||
surcharge_applied: boolean;
|
||||
surcharge_dataset: { effective_date: string; source: Record<string, unknown> };
|
||||
missing_rate_materials: string[];
|
||||
missing_supply_materials: string[];
|
||||
missing_install_by_materials: string[];
|
||||
double_count_warnings: string[];
|
||||
skipped_by_destination: Record<string, number>;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
export interface UnitQuantityStructure {
|
||||
structure_id: string | null;
|
||||
type_id: string;
|
||||
name: string;
|
||||
length_m: number;
|
||||
height_m: number;
|
||||
notes: string[];
|
||||
components: {
|
||||
name: string;
|
||||
unit: string;
|
||||
amount: number;
|
||||
destination: string;
|
||||
basis: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface MaterialResponse {
|
||||
unit_quantity: {
|
||||
structures: UnitQuantityStructure[];
|
||||
totals: { name: string; unit: string; amount: number; destination: string }[];
|
||||
surcharge_applied: boolean;
|
||||
mix_components_found: string[];
|
||||
structure_count: number;
|
||||
};
|
||||
material: MaterialTable;
|
||||
skipped_structures: string[];
|
||||
structure_count: number;
|
||||
}
|
||||
|
||||
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
|
||||
const DESTINATION_LABELS: Record<string, string> = {
|
||||
earthwork: "토공 합산",
|
||||
material: "자재총괄",
|
||||
unit_price: "일위대가",
|
||||
};
|
||||
|
||||
function num(value: number | null | undefined, digits: number): string {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) return "";
|
||||
return value.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
}
|
||||
|
||||
function textCell(text: string, className?: string): HTMLTableCellElement {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = text;
|
||||
if (className) td.className = className;
|
||||
return td;
|
||||
}
|
||||
|
||||
function headRow(labels: string[]): HTMLTableSectionElement {
|
||||
const head = document.createElement("thead");
|
||||
const tr = document.createElement("tr");
|
||||
for (const label of labels) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
tr.append(th);
|
||||
}
|
||||
head.append(tr);
|
||||
return head;
|
||||
}
|
||||
|
||||
/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */
|
||||
function warning(title: string, items: string[]): HTMLElement | null {
|
||||
if (!items.length) return null;
|
||||
const element = document.createElement("p");
|
||||
element.className = "b08-grid__caption b08-grid__caption--warn";
|
||||
element.textContent = `${title}: ${items.join(" · ")}`;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
|
||||
export function renderMaterialGrid(table: MaterialTable): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b08-grid";
|
||||
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b08-grid__caption";
|
||||
const edition = table.surcharge_dataset?.effective_date || "판 미상";
|
||||
caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`;
|
||||
wrap.append(caption);
|
||||
|
||||
for (const notice of [
|
||||
warning("⚠ 중복 할증 위험", table.double_count_warnings),
|
||||
warning("할증률 미확보", table.missing_rate_materials),
|
||||
warning("관급구분 미분류", table.missing_supply_materials),
|
||||
warning("설치 주체 미지정(관급)", table.missing_install_by_materials),
|
||||
]) {
|
||||
if (notice) wrap.append(notice);
|
||||
}
|
||||
|
||||
if (!table.rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b08-quantity__message";
|
||||
empty.textContent = "구조물에서 나온 자재가 없음 — 구조물을 먼저 배치할 것";
|
||||
wrap.append(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const scroller = document.createElement("div");
|
||||
scroller.className = "b08-grid__scroll";
|
||||
const element = document.createElement("table");
|
||||
element.className = "b08-grid__table b08-grid__table--summary";
|
||||
element.append(headRow(table.columns));
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.append(textCell(row.name, "b08-grid__station"));
|
||||
tr.append(textCell(row.unit, "b08-grid__unit"));
|
||||
tr.append(textCell(num(row.net_amount, 2)));
|
||||
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
|
||||
tr.append(textCell(row.surcharge_pct === null ? "-" : num(row.surcharge_pct, 0)));
|
||||
tr.append(textCell(num(row.total_amount, 2)));
|
||||
tr.append(textCell(row.supply_label));
|
||||
tr.append(textCell(row.install_by_label));
|
||||
tr.append(textCell(row.note, "b08-grid__note"));
|
||||
body.append(tr);
|
||||
}
|
||||
|
||||
element.append(body);
|
||||
scroller.append(element);
|
||||
wrap.append(scroller);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** 구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다. */
|
||||
export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "b08-grid";
|
||||
const unit = response.unit_quantity;
|
||||
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b08-grid__caption";
|
||||
caption.textContent = `구조물 ${unit.structure_count}개 · 치수는 구조물 정본(B05)에서 · 할증 전 값`;
|
||||
wrap.append(caption);
|
||||
|
||||
// ㉢ 배합이 섞였으면 화면에도 뜬다 — 코드 검사만으로는 사람이 모른다.
|
||||
const mixed = warning("⚠ 배합 성분이 섞였음(B09 일위대가와 이중계상)", unit.mix_components_found);
|
||||
if (mixed) wrap.append(mixed);
|
||||
const skipped = warning("건너뛴 구조물", response.skipped_structures);
|
||||
if (skipped) wrap.append(skipped);
|
||||
|
||||
if (!unit.structures.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b08-quantity__message";
|
||||
empty.textContent = "배치된 구조물이 없음";
|
||||
wrap.append(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const scroller = document.createElement("div");
|
||||
scroller.className = "b08-grid__scroll";
|
||||
const element = document.createElement("table");
|
||||
element.className = "b08-grid__table b08-grid__table--summary";
|
||||
element.append(headRow(["구조물", "규격", "성분", "단위", "수량", "갈 곳", "근거"]));
|
||||
|
||||
const body = document.createElement("tbody");
|
||||
for (const structure of unit.structures) {
|
||||
const spec = `H=${num(structure.height_m, 1)} · L=${num(structure.length_m, 1)}m`;
|
||||
if (!structure.components.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.append(textCell(structure.name, "b08-grid__station"));
|
||||
tr.append(textCell(spec));
|
||||
const note = textCell(structure.notes.join(" · "), "b08-grid__note");
|
||||
note.colSpan = 5;
|
||||
tr.append(note);
|
||||
body.append(tr);
|
||||
continue;
|
||||
}
|
||||
let first = true;
|
||||
for (const component of structure.components) {
|
||||
const tr = document.createElement("tr");
|
||||
// 같은 구조물이 이어지면 이름을 한 번만 적는다 — 실무 시트가 그렇게 병합해 둔다.
|
||||
tr.append(textCell(first ? structure.name : "", "b08-grid__station"));
|
||||
tr.append(textCell(first ? spec : ""));
|
||||
first = false;
|
||||
tr.append(textCell(component.name));
|
||||
tr.append(textCell(component.unit, "b08-grid__unit"));
|
||||
tr.append(textCell(num(component.amount, 3)));
|
||||
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
|
||||
tr.append(textCell(component.basis, "b08-grid__note"));
|
||||
body.append(tr);
|
||||
}
|
||||
}
|
||||
|
||||
element.append(body);
|
||||
scroller.append(element);
|
||||
wrap.append(scroller);
|
||||
return wrap;
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
|
||||
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
|
||||
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
|
||||
import {
|
||||
renderMaterialGrid,
|
||||
renderUnitQuantityGrid,
|
||||
type MaterialResponse,
|
||||
} from "./B08_Quantity_UI_MaterialGrid";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -47,6 +52,16 @@ async function fetchEarthworkTable(projectId: string): Promise<EarthworkTable> {
|
||||
return (await response.json()) as EarthworkTable;
|
||||
}
|
||||
|
||||
/** 구조물 원단위·자재총괄을 받아 온다. 한 번에 받는 까닭은 자재총괄이 원단위의 부분집합이라서다. */
|
||||
async function fetchMaterialSummary(projectId: string): Promise<MaterialResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/material-summary`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`material summary failed: ${response.status}`);
|
||||
return (await response.json()) as MaterialResponse;
|
||||
}
|
||||
|
||||
/** [저장] — 산출 조건을 정본에 남긴다. `quantity` 구획만 간다(서버가 막고 있다). */
|
||||
async function saveQuantitySettings(projectId: string, draft: DraftSettings): Promise<void> {
|
||||
const response = await fetch(
|
||||
@@ -78,12 +93,21 @@ function field(label: string, value: string): HTMLElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */
|
||||
const RATIO_LABEL_KEYS: Record<string, keyof typeof ui_locales> = {
|
||||
fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction",
|
||||
seed_spray_fill: "B08_Quantity_Ratio_SeedFill",
|
||||
seed_spray_cut: "B08_Quantity_Ratio_SeedCut",
|
||||
obstacle_removal: "B08_Quantity_Ratio_TreeRemoval",
|
||||
};
|
||||
|
||||
function ratioLabel(key: string): string {
|
||||
const localeKey = RATIO_LABEL_KEYS[key];
|
||||
return localeKey ? L(localeKey) : key;
|
||||
}
|
||||
|
||||
/** 반영률·비율 입력 한 칸. 값은 **캐시에만** 쌓이고 [저장]에서 정본으로 간다(5장). */
|
||||
function numberField(
|
||||
label: string,
|
||||
value: number,
|
||||
onInput: (value: number) => void,
|
||||
): HTMLElement {
|
||||
function numberField(label: string, value: number, onInput: (value: number) => void): HTMLElement {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b08-quantity__field";
|
||||
const name = document.createElement("span");
|
||||
@@ -149,7 +173,7 @@ function buildQuantitySidePanel(
|
||||
panel.append(field(L("B08_Quantity_Side_Ratios"), ""));
|
||||
for (const key of Object.keys(ratios)) {
|
||||
panel.append(
|
||||
numberField(key, draft.application_ratios_pct[key] ?? 100, (value) => {
|
||||
numberField(ratioLabel(key), draft.application_ratios_pct[key] ?? 100, (value) => {
|
||||
draft.application_ratios_pct[key] = value;
|
||||
draft.dirty = true;
|
||||
}),
|
||||
@@ -203,13 +227,20 @@ function buildQuantitySidePanel(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b08-quantity__actions ui-sidebar-actions";
|
||||
actions.append(confirmButton);
|
||||
// [초기화]는 이번에 달지 않는다 — 5장의 [초기화]는 초기값(`initial_snapshot/`)을 작업본에
|
||||
// 덮어쓰는 것인데 설정에는 대응하는 초기값이 아직 없다. 재계산 단추로 오해될 자리다.
|
||||
// TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다.
|
||||
actions.append(saveButton, confirmButton);
|
||||
panel.append(actions);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */
|
||||
function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLElement {
|
||||
function buildQuantityBody(
|
||||
table: EarthworkTable | null,
|
||||
failed: boolean,
|
||||
material: MaterialResponse | null,
|
||||
): HTMLElement {
|
||||
const body = document.createElement("div");
|
||||
body.className = "b08-quantity__body";
|
||||
|
||||
@@ -239,9 +270,7 @@ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLE
|
||||
{
|
||||
label: L("B08_Quantity_Tab_Summary"),
|
||||
build: () =>
|
||||
table.summary
|
||||
? renderSummaryGrid(table.summary)
|
||||
: message(L("B08_Quantity_Grid_Empty")),
|
||||
table.summary ? renderSummaryGrid(table.summary) : message(L("B08_Quantity_Grid_Empty")),
|
||||
},
|
||||
{
|
||||
label: L("B08_Quantity_Tab_Haul"),
|
||||
@@ -250,6 +279,18 @@ function buildQuantityBody(table: EarthworkTable | null, failed: boolean): HTMLE
|
||||
? renderHaulGrid(table.haul, Boolean(table.haul_available))
|
||||
: message(L("B08_Quantity_Haul_Missing")),
|
||||
},
|
||||
{
|
||||
label: L("B08_Quantity_Tab_UnitQuantity"),
|
||||
build: () =>
|
||||
material ? renderUnitQuantityGrid(material) : message(L("B08_Quantity_Material_Failed")),
|
||||
},
|
||||
{
|
||||
label: L("B08_Quantity_Tab_Material"),
|
||||
build: () =>
|
||||
material
|
||||
? renderMaterialGrid(material.material)
|
||||
: message(L("B08_Quantity_Material_Failed")),
|
||||
},
|
||||
];
|
||||
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
@@ -281,6 +322,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
|
||||
// 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다.
|
||||
let table: EarthworkTable | null = null;
|
||||
let material: MaterialResponse | null = null;
|
||||
let failed = false;
|
||||
if (projectId) {
|
||||
try {
|
||||
@@ -288,6 +330,12 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
// 자재총괄은 따로 받는다 — 구조물이 없어도 토적표는 서야 하므로 실패를 옮기지 않는다.
|
||||
try {
|
||||
material = await fetchMaterialSummary(projectId);
|
||||
} catch {
|
||||
material = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장).
|
||||
@@ -324,7 +372,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
|
||||
steps: workflowSteps(),
|
||||
activeStep: 5,
|
||||
leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
|
||||
mainContent: buildQuantityBody(table, failed),
|
||||
mainContent: buildQuantityBody(table, failed, material),
|
||||
stages: workflowState?.stages,
|
||||
currentStage: workflowState?.current_stage,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
|
||||
@@ -43,7 +43,14 @@ export interface HaulRow {
|
||||
|
||||
export interface HaulTable {
|
||||
rows: HaulRow[];
|
||||
legs: { equipment: string; ground: string; volume_m3: number; distance_m: number; from_m: number; to_m: number }[];
|
||||
legs: {
|
||||
equipment: string;
|
||||
ground: string;
|
||||
volume_m3: number;
|
||||
distance_m: number;
|
||||
from_m: number;
|
||||
to_m: number;
|
||||
}[];
|
||||
bill_row_count: number;
|
||||
}
|
||||
|
||||
@@ -141,7 +148,14 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen
|
||||
|
||||
const head = document.createElement("thead");
|
||||
const headRow = document.createElement("tr");
|
||||
for (const label of ["운반수단", "지반유형", "토량(㎥)", "평균운반거리(m)", "근거 구간", "비고"]) {
|
||||
for (const label of [
|
||||
"운반수단",
|
||||
"지반유형",
|
||||
"토량(㎥)",
|
||||
"평균운반거리(m)",
|
||||
"근거 구간",
|
||||
"비고",
|
||||
]) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = label;
|
||||
headRow.append(th);
|
||||
|
||||
@@ -71,6 +71,10 @@ def default_settings() -> dict[str, Any]:
|
||||
"conversion_factors_override": None,
|
||||
"haul_limits_m_override": None,
|
||||
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
|
||||
# 자재총괄의 관급/사급 구분 — `{자재명: "public"|"private"}`.
|
||||
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
|
||||
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
|
||||
"material_supply": {},
|
||||
"dataset_versions": {},
|
||||
},
|
||||
"estimation": {
|
||||
|
||||
@@ -60,6 +60,7 @@ from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
|
||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||
from common_util.common_util_audit import note_api_call, record_call_burst
|
||||
from common_util.common_util_auth import (
|
||||
@@ -538,6 +539,7 @@ app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
||||
app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||
app.include_router(b09_estimation_router, dependencies=protected_with_company)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "data_material_surcharge_manifest",
|
||||
"generated_at": "2026-09-07T00:00:00+09:00",
|
||||
"built_by": "수작업 — 산림품셈 1-3-1 전사 + 건설품셈 1-3-1 대조(2026-09-07)",
|
||||
"files": [
|
||||
{
|
||||
"file": "material_surcharge_2026-01-01.json",
|
||||
"sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc",
|
||||
"size_bytes": 4724
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "material_surcharge",
|
||||
"effective_date": "2026-01-01",
|
||||
"source": {
|
||||
"primary": {
|
||||
"doc": "산림사업 표준품셈 제1장 1-3-1 재료의 할증",
|
||||
"via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md"
|
||||
},
|
||||
"supplementary": {
|
||||
"doc": "건설공사 표준품셈 1-3-1 재료의 할증('23년 보완)",
|
||||
"via": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제1장_적용기준.md",
|
||||
"note": "산림품셈에 없는 자재만 보완용으로 본다(PLAN 8-5). 실린 값은 출처를 pumsem 필드로 구분."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
"no_invented_values": true,
|
||||
"unknown_material_is_flagged": true
|
||||
},
|
||||
"rates_pct": [
|
||||
{
|
||||
"material": "시멘트",
|
||||
"rate": 2,
|
||||
"condition": "정치식",
|
||||
"alt_rate": 3,
|
||||
"alt_condition": "기타",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "잔골재",
|
||||
"rate": 10,
|
||||
"alt_rate": 12,
|
||||
"alt_condition": "기타",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "채움재",
|
||||
"rate": 10,
|
||||
"alt_rate": 12,
|
||||
"alt_condition": "기타",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "굵은골재",
|
||||
"rate": 3,
|
||||
"alt_rate": 5,
|
||||
"alt_condition": "기타",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "모래",
|
||||
"rate": 6,
|
||||
"condition": "노반재료",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "부순돌",
|
||||
"rate": 4,
|
||||
"condition": "노반재료",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "자갈",
|
||||
"rate": 4,
|
||||
"condition": "노반재료",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "점질토",
|
||||
"rate": 6,
|
||||
"condition": "노반재료",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "이형철근",
|
||||
"rate": 3,
|
||||
"alt_rate": 7,
|
||||
"alt_condition": "복잡 구조물 주철근",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "원형철근",
|
||||
"rate": 5,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "강판",
|
||||
"rate": 10,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "각재",
|
||||
"rate": 5,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "판재",
|
||||
"rate": 10,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "레미콘",
|
||||
"rate": 2,
|
||||
"condition": "무근",
|
||||
"alt_rate": 1,
|
||||
"alt_condition": "철근",
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "흄관",
|
||||
"rate": 3,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "떼",
|
||||
"rate": 10,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "초화류",
|
||||
"rate": 10,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "사방용 수목",
|
||||
"rate": 10,
|
||||
"pumsem": "forest"
|
||||
},
|
||||
{
|
||||
"material": "원석",
|
||||
"rate": 30,
|
||||
"condition": "마름돌용",
|
||||
"pumsem": "forest"
|
||||
}
|
||||
],
|
||||
"observed_practice": {
|
||||
"note": "울진 총괄집계 관측 — 참고이지 기본값이 아니다(PLAN 8-10 ★ 법대로).",
|
||||
"values": {
|
||||
"모래": 10,
|
||||
"자갈": 3,
|
||||
"혼합석": 2,
|
||||
"시멘트": 2,
|
||||
"떼": 10
|
||||
}
|
||||
},
|
||||
"candidates_pending_user": {
|
||||
"note": "건설품셈에서 이름은 찾았으나 **적용 조건이 우리 쓰임과 다른** 것. 지식DB는 근거·후보 가이드이지 값을 확정하는 곳이 아니므로(CLAUDE.md 3장) 엔진은 이 값을 쓰지 않고 「할증률 미확보」로 둔다. 사용자 확정 후 rates_pct 로 옮길 것.",
|
||||
"items": [
|
||||
{
|
||||
"material": "막자갈",
|
||||
"rate": 4,
|
||||
"pumsem": "const",
|
||||
"listed_condition": "노상 및 노반재료(선택층·보조기층·기층)",
|
||||
"our_usage": "돌쌓기 뒤채움",
|
||||
"why_not_applied": "조건이 노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음"
|
||||
}
|
||||
]
|
||||
},
|
||||
"not_found": {
|
||||
"note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.",
|
||||
"materials": ["야면석", "고임돌", "물구멍"],
|
||||
"checked": [
|
||||
"산림품셈 1-3-1 전 19종",
|
||||
"건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)",
|
||||
"건설품셈 제7장 돌공사 — 재료 할증률표 없음"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -726,27 +726,29 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"],
|
||||
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
|
||||
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
|
||||
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
|
||||
B08_Quantity_Material_Failed: [
|
||||
"자재총괄을 불러오지 못했습니다.",
|
||||
"Failed to load the material summary.",
|
||||
],
|
||||
B08_Quantity_Haul_Missing: [
|
||||
"운반계획이 아직 없습니다. 종단설계에서 [확정]을 누르면 만들어집니다.",
|
||||
"No haul plan yet. Press [Confirm] on the profile design to build it.",
|
||||
],
|
||||
B08_Quantity_Haul_Excluded: ["내역 제외", "Not billed"],
|
||||
B08_Quantity_Side_Ratios: ["반영률(%)", "Application ratios (%)"],
|
||||
/* 반영률 항목 이름 — 서버 키를 사람이 읽는 말로. 거창 실무 시트 문구를 따른다. */
|
||||
B08_Quantity_Ratio_FillCompaction: ["성토면다짐", "Fill slope compaction"],
|
||||
B08_Quantity_Ratio_SeedFill: ["초류종자살포(성토면)", "Seed spray (fill)"],
|
||||
B08_Quantity_Ratio_SeedCut: ["초류종자살포(절토면)", "Seed spray (cut)"],
|
||||
B08_Quantity_Ratio_TreeRemoval: ["지장목제거", "Obstacle removal"],
|
||||
B08_Quantity_Side_RockSet: ["암 갈래 세트", "Rock class set"],
|
||||
B08_Quantity_Side_RockRatios: ["지반 구성비(%)", "Ground composition (%)"],
|
||||
B08_Quantity_Btn_Save: ["저장", "Save"],
|
||||
B08_Quantity_Save_Success: [
|
||||
"산출 조건을 저장했습니다.",
|
||||
"Calculation settings saved.",
|
||||
],
|
||||
B08_Quantity_Save_Failed: [
|
||||
"산출 조건을 저장하지 못했습니다.",
|
||||
"Failed to save the settings.",
|
||||
],
|
||||
B08_Quantity_Unsaved: [
|
||||
"저장하지 않은 변경이 있습니다.",
|
||||
"You have unsaved changes.",
|
||||
],
|
||||
B08_Quantity_Save_Success: ["산출 조건을 저장했습니다.", "Calculation settings saved."],
|
||||
B08_Quantity_Save_Failed: ["산출 조건을 저장하지 못했습니다.", "Failed to save the settings."],
|
||||
B08_Quantity_Unsaved: ["저장하지 않은 변경이 있습니다.", "You have unsaved changes."],
|
||||
B08_Quantity_Side_Method: ["산출법", "Method"],
|
||||
B08_Quantity_Side_Method_Value: ["평균단면적법", "Average end area"],
|
||||
B08_Quantity_Side_Factors: [
|
||||
|
||||
Reference in New Issue
Block a user