feat(B08): 자재총괄 — 할증이 붙는 유일한 자리

구조물 원단위의 `destination == "material"` 성분만 모아 자재별 합산 후
할증률을 한 번만 적용. 열은 순수량·할증률·합계 + 관급구분·설치주체·비고이며
금액은 없음(B09 경계).

- 할증률은 코드가 아니라 데이터 — `resources/data_material_surcharge/`
  (품셈 1-3-1 재료 할증률 19종 + sha256 매니페스트). 실무 관측값은
  `observed_practice` 로 분리(법대로 원칙).
- 표에 없는 자재는 0 % 로 넘기지 않고 「할증률 미확보」로 표시.
  이름 조회는 정확 일치 — 부분일치면 `막자갈` 이 `자갈` 할증을 뭄.
- 이중계상 방어 ㉠ — 앞 단계 `surcharge_applied` 깃발을 실제로 읽어 경고.
  자재총괄 응답은 `True`, 원단위표는 `False` 로 어느 쪽 값인지 명시.
- 관급/사급 이름은 B09 와 동일(`owner_supplied`/`contractor_supplied`).
  관급 줄에만 설치 주체(`install_by`)를 붙이고, 미지정은 기본값으로
  때우지 않고 드러냄 — 안전관리비 대상액이 「도급자설치 관급금액」이라서임.
- 라우터 `GET /quantity/material-summary` 신설, 화면에 「구조물 원단위」·
  「자재총괄」 탭 추가. `design_owner` 가 붙은 타입(측구)은 중복 계상 방지로 제외.

검증 — 전용 테스트 23건 통과, 전체 회귀 473 passed(기존 B05 깨짐 1건 제외).
공용 브라우저 실조작으로 탭 5장·머리글·값 4줄·미확보 안내 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 23:43:46 +09:00
co-authored by Claude Opus 5
parent fc40265dbe
commit 6a487fc6e3
9 changed files with 759 additions and 2 deletions
@@ -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,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),
}
)
@@ -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;
}
+40 -2
View File
@@ -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(
@@ -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<void> {
// 표는 한 번만 받아 좌측 패널(계수 표시)과 우측 그리드가 함께 쓴다.
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<void> {
} 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<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,
@@ -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": {
+2
View File
@@ -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 재료 할증률표 전사 (지식DB 수량산출_일반.md §3)",
"files": [
{
"file": "material_surcharge_2026-01-01.json",
"sha256": "77ffb9d8ae916252c725d28a4218ff7ecbff6a47bad1b00be8686663e17deb68",
"size_bytes": 2098
}
]
}
@@ -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 }
}
}
+6
View File
@@ -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.",