diff --git a/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py new file mode 100644 index 00000000..90df4a85 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_MaterialSummary.py @@ -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), + } diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py new file mode 100644 index 00000000..eeaa6d68 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -0,0 +1,105 @@ +"""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_MaterialSummary import build_table as build_material_table +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table +from common_util.common_util_project_settings import quantity_settings +from common_util.common_util_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), + } + ) diff --git a/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts new file mode 100644 index 00000000..bb301fd8 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_MaterialGrid.ts @@ -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 }; + missing_rate_materials: string[]; + missing_supply_materials: string[]; + missing_install_by_materials: string[]; + double_count_warnings: string[]; + skipped_by_destination: Record; + 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 = { + 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; +} diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 4d07414f..e9e7b040 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -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 { return (await response.json()) as EarthworkTable; } +/** 구조물 원단위·자재총괄을 받아 온다. 한 번에 받는 까닭은 자재총괄이 원단위의 부분집합이라서다. */ +async function fetchMaterialSummary(projectId: string): Promise { + 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 { const response = await fetch( @@ -221,7 +236,11 @@ function buildQuantitySidePanel( } /** 우측 본문 — 시트 탭 + 고른 장의 표. 실무 산출서의 시트를 탭으로 옮긴 것이다. */ -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"; @@ -260,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[] = []; @@ -291,6 +322,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { // 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다. let table: EarthworkTable | null = null; + let material: MaterialResponse | null = null; let failed = false; if (projectId) { try { @@ -298,6 +330,12 @@ export async function renderB08Quantity(root: HTMLElement): Promise { } catch { failed = true; } + // 자재총괄은 따로 받는다 — 구조물이 없어도 토적표는 서야 하므로 실패를 옮기지 않는다. + try { + material = await fetchMaterialSummary(projectId); + } catch { + material = null; + } } // 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다(CLAUDE.md 5장). @@ -334,7 +372,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise { 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, diff --git a/common_util/common_util_project_settings.py b/common_util/common_util_project_settings.py index 7e229376..c9099467 100644 --- a/common_util/common_util_project_settings.py +++ b/common_util/common_util_project_settings.py @@ -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": { diff --git a/main.py b/main.py index 0c89aaa5..135572f7 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/data_material_surcharge/_manifest.json b/resources/data_material_surcharge/_manifest.json new file mode 100644 index 00000000..d0b0e74a --- /dev/null +++ b/resources/data_material_surcharge/_manifest.json @@ -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 재료 할증률표 전사 (지식DB 수량산출_일반.md §3)", + "files": [ + { + "file": "material_surcharge_2026-01-01.json", + "sha256": "77ffb9d8ae916252c725d28a4218ff7ecbff6a47bad1b00be8686663e17deb68", + "size_bytes": 2098 + } + ] +} diff --git a/resources/data_material_surcharge/material_surcharge_2026-01-01.json b/resources/data_material_surcharge/material_surcharge_2026-01-01.json new file mode 100644 index 00000000..aaf05787 --- /dev/null +++ b/resources/data_material_surcharge/material_surcharge_2026-01-01.json @@ -0,0 +1,51 @@ +{ + "schema_version": "1.0", + "dataset_id": "material_surcharge", + "effective_date": "2026-01-01", + "source": { + "doc": "산림사업 표준품셈 (산림청고시 제2025-82호) 제1장 1-3-1 재료의 할증", + "via": "resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md", + "note": "품셈 항목에 할증이 포함·표시된 경우 중복 적용 금지 — 자재총괄 한 곳에서만 붙인다." + }, + "policy": { + "no_invented_values": true, + "unknown_material_is_flagged": true + }, + "rates_pct": [ + { + "material": "시멘트", + "rate": 2, + "condition": "정치식", + "alt_rate": 3, + "alt_condition": "기타" + }, + { "material": "잔골재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, + { "material": "채움재", "rate": 10, "alt_rate": 12, "alt_condition": "기타" }, + { "material": "굵은골재", "rate": 3, "alt_rate": 5, "alt_condition": "기타" }, + { "material": "모래", "rate": 6, "condition": "노반재료" }, + { "material": "부순돌", "rate": 4, "condition": "노반재료" }, + { "material": "자갈", "rate": 4, "condition": "노반재료" }, + { "material": "점질토", "rate": 6, "condition": "노반재료" }, + { "material": "이형철근", "rate": 3, "alt_rate": 7, "alt_condition": "복잡 구조물 주철근" }, + { "material": "원형철근", "rate": 5 }, + { "material": "강판", "rate": 10 }, + { "material": "각재", "rate": 5 }, + { "material": "판재", "rate": 10 }, + { + "material": "레미콘", + "rate": 2, + "condition": "무근", + "alt_rate": 1, + "alt_condition": "철근" + }, + { "material": "흄관", "rate": 3 }, + { "material": "떼", "rate": 10 }, + { "material": "초화류", "rate": 10 }, + { "material": "사방용 수목", "rate": 10 }, + { "material": "원석", "rate": 30, "condition": "마름돌용" } + ], + "observed_practice": { + "note": "울진 총괄집계 관측 — 참고이지 기본값이 아니다(PLAN 8-10 ★ 법대로).", + "values": { "모래": 10, "자갈": 3, "혼합석": 2, "시멘트": 2, "떼": 10 } + } +} diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 8e4c7cea..27a1ff93 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -621,6 +621,12 @@ 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.",