feat(B08): 거푸집 사용횟수 + 공종코드 잇기

- 거푸집 사용횟수는 **관측값이 아니라 법**임. 품셈 1-7-1 이 구조물 종류별로
  정해 둠(옹벽 3회 · 보호공 기초 6회). 원문 문구를 데이터에 싣고 우리 구조물이
  어느 예시에 걸리는지 적음. 걸리는 예시가 없으면 「사용횟수 미확보」.
- ⚠ 횟수별 재료 환산(품셈 12-4 합판 3회 46.1 %)은 **하지 않음**. 그 비율은
  일위대가 재료비에 걸리는 값이라 B08 이 곱하면 B09 와 겹쳐 두 번 줌.
  B08 이 내는 것은 접촉 면적 그대로 + 몇 회짜리인가까지. 시험으로 못 박음.
- 동바리는 슬래브를 떠받칠 때 쓰는 것이라 지금 서는 구조물(옹벽·집수정)은
  대상이 아님. 0 으로 적지 않고 「대상 없음 + 사유」로 냄.
- 거푸집 이름은 정확 일치로만 봄 — 부분일치면 「거푸집씻기」(공사용수)가 걸림.

공종코드 잇기
- 집수정 → FP-12-15, 물넘이포장 → FP-12-06 로 이음.
- 옹벽은 **품셈 12장에 그 이름의 공종이 없음**. 빈 코드로 두면 「매핑을 못 찾은
  줄」과 구별이 안 되므로 `composite` 로 묶음(타설+거푸집+철근+기초잡석)을 적음.
  일위대가 조립은 B09 몫이고 B08 은 물량과 묶음만 넘김.

검증 — 거푸집 8건 · 인계 45건 통과, 전체 558 passed. tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 00:45:02 +09:00
co-authored by Claude Opus 5
parent 513769796f
commit 8e78940b7a
6 changed files with 314 additions and 32 deletions
@@ -0,0 +1,127 @@
"""거푸집 사용횟수 — 접촉 면적에 **몇 회 쓰는 거푸집인지**를 붙인다 (B08 일감 ⑩).
⚠ 사용횟수는 **관측값이 아니라 법이다**
품셈 1-7-1 이 구조물 종류별로 정해 둔다 — 「3회 … 옹벽, 파라펫트, 날개벽 등 약간 복잡한
구조」. 그래서 실무 관측값으로 갈음하지 않고 **원문 문구를 그대로 데이터에 싣고** 우리
구조물이 그 줄의 어느 예시에 걸리는지를 적는다. 걸리는 예시가 없으면 지어내지 않는다.
⚠⚠ **횟수별 재료 환산은 여기서 하지 않는다** (이중계상)
품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」(합판 3회 46.1 % 등)은 **일위대가
재료비**에 걸리는 값이다. B08 이 면적에 그 비율을 곱해 넘기면 B09 가 또 곱해 두 번 준다.
**B08 이 내는 것은 「접촉 면적 + 몇 회짜리인가」까지다.** 비율표는 참고로만 싣는다.
⚠ 동바리는 지금 대상이 없다
강관동바리(12-20)는 **슬래브를 떠받칠 때** 쓴다. 지금 서는 구조물(옹벽·집수정)은 벽체
거푸집뿐이라 대상이 아니고, 대상이 될 BOX암거·세월교는 치수·원단위가 미확보라
슬래브 면적 자체가 안 나온다. **없는 것을 0 으로 적지 않고 「대상 없음」이라고 말한다.**
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_formwork"
DATASET_PREFIX = "formwork_reuse_"
#: 거푸집으로 보는 성분 이름. 정확히 같은 이름으로만 본다 — 부분일치면 「거푸집씻기」가 걸린다.
FORMWORK_NAMES = frozenset({"합판거푸집", "유로폼", "문양거푸집", "거푸집"})
NOTE_REUSE_MISSING = "사용횟수 미확보"
NOTE_NOT_APPLICABLE = "거푸집 대상 아님"
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 FormworkTable:
"""사용횟수표 한 판."""
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
type_map: list[dict[str, Any]] = field(default_factory=list)
reuse_by_class: list[dict[str, Any]] = field(default_factory=list)
reuse_ratio_pct: dict[str, Any] = field(default_factory=dict)
shoring: dict[str, Any] = field(default_factory=dict)
def for_type(self, type_id: str) -> dict[str, Any] | None:
for row in self.type_map:
if row.get("type_id") == type_id:
return row
return None
def load_formwork_table(path: Path | None = None) -> FormworkTable:
"""사용횟수표를 읽는다. 파일이 없으면 **빈 표** — 전부 「미확보」로 드러난다."""
target = path or _latest_dataset_path()
if target is None or not target.is_file():
return FormworkTable()
payload = json.loads(target.read_text(encoding="utf-8"))
return FormworkTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
type_map=list(payload.get("type_map") or []),
reuse_by_class=list(payload.get("reuse_by_class") or []),
reuse_ratio_pct=payload.get("reuse_ratio_pct") or {},
shoring=payload.get("shoring") or {},
)
def annotate(
structures: list[dict[str, Any]], table: FormworkTable | None = None
) -> tuple[list[str], list[str]]:
"""산출물의 거푸집 성분에 사용횟수를 달아 준다. (알림, 미확보 종류) 를 돌려준다.
성분 딕셔너리를 **그 자리에서** 고친다 — 거푸집 줄만 손대고 나머지는 건드리지 않는다.
"""
found = table or load_formwork_table()
notes: list[str] = []
missing: list[str] = []
for structure in structures:
type_id = str(structure.get("type_id") or "")
entry = found.for_type(type_id)
targets = [
component
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in FORMWORK_NAMES
]
if not targets:
continue
if entry is None:
missing.append(type_id)
for component in targets:
component["reuse_count"] = None
component["reuse_note"] = NOTE_REUSE_MISSING
continue
count = entry.get("reuse_count")
for component in targets:
component["reuse_count"] = count
component["reuse_note"] = (
f"품셈 1-7-1 {count}회 — 「{entry.get('matched_example')}"
if count
else NOTE_NOT_APPLICABLE
)
if count:
notes.append(f"{structure.get('name') or type_id} 거푸집 {count}회 (품셈 1-7-1)")
else:
missing.append(type_id)
return notes, sorted(set(missing))
def shoring_status(table: FormworkTable | None = None) -> dict[str, Any]:
"""동바리 — **대상이 없으면 없다고 말한다.** 0 으로 적으면 「없음」과 구별이 안 된다."""
found = table or load_formwork_table()
shoring = found.shoring or {}
return {
"applicable": False,
"reason": str(shoring.get("note") or "슬래브 구조물이 없어 동바리 대상이 아님"),
"pending_types": list(shoring.get("targets_pending") or []),
}
+23 -4
View File
@@ -85,6 +85,7 @@ class WorkItemMapping:
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)
composite: dict[str, Any] = field(default_factory=dict)
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
@@ -111,6 +112,16 @@ class WorkItemMapping:
return row
return None
def composite_for(self, type_id: str) -> dict[str, Any] | None:
"""품셈에 그 이름의 공종이 없어 **여러 공종을 묶는** 자리인가.
빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다 — 묶음을 적어 구별한다.
"""
for row in self.composite.get("items") or []:
if row.get("type_id") == type_id:
return row
return None
def load_mapping(path: Path | None = None) -> WorkItemMapping:
"""매핑표를 읽는다. 파일이 없으면 **빈 표** — 전 줄이 `unmatched` 로 드러난다."""
@@ -124,6 +135,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
haul=list(payload.get("haul") or []),
structure=list(payload.get("structure") or []),
pending_user=payload.get("pending_user") or {},
composite=payload.get("composite") or {},
)
@@ -263,7 +275,8 @@ def _structure_rows(
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:
composite = mapping.composite_for(type_id) if code is None else None
if code is None and composite is None:
unmatched.append(f"구조물({type_id})")
length = float(structure.get("length_m") or 0.0)
rows.append(
@@ -283,8 +296,10 @@ def _structure_rows(
"station_from": structure.get("start_m"),
"station_to": structure.get("end_m"),
"spec_detail": _spec_detail(structure),
# 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다.
"composite_parts": (composite or {}).get("parts"),
"in_bill": True,
"in_bill_reason": "",
"in_bill_reason": (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE,
}
)
@@ -422,8 +437,12 @@ def verify_bill_flags(handoff: dict[str, Any]) -> list[str]:
"""
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")))
if not row.get("in_bill") or row.get("work_item_code"):
continue
# 묶음으로 서는 줄은 코드가 없어도 정상이다 — 무엇으로 묶이는지 적혀 있다.
if row.get("composite_parts"):
continue
found.append(str(row.get("name")))
return found
@@ -41,6 +41,8 @@ from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
expand_observed,
load_observed_table,
)
from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork
from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status
from common_util.common_util_quantity_spread import spread_by_unit
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
@@ -406,32 +408,40 @@ def build_table(
)
entry["amount"] += component.amount
payload_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,
"basis_kind": component.basis_kind,
"source": component.source,
}
for component in item.components
],
}
for item in quantities
]
# 거푸집 줄에 **몇 회짜리인지**를 달아 준다. 횟수별 재료 환산은 하지 않는다(B09 몫).
formwork_notes, formwork_missing = annotate_formwork(payload_structures)
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,
"basis_kind": component.basis_kind,
"source": component.source,
}
for component in item.components
],
}
for item in quantities
],
"structures": payload_structures,
"formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
"shoring": shoring_status(),
"totals": sorted(totals.values(), key=lambda entry: entry["name"]),
# 할증 전 값임을 응답에 못 박는다 — 자재총괄이 한 번만 붙인다(㉠).
"surcharge_applied": False,
+26 -1
View File
@@ -55,6 +55,9 @@ export interface UnitQuantityStructure {
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
basis_kind?: string;
source?: string;
/** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */
reuse_count?: number | null;
reuse_note?: string;
}[];
}
@@ -71,6 +74,13 @@ export interface MaterialResponse {
structure_count: number;
}
/** 거푸집·동바리 안내에 쓰는 값. */
export interface FormworkInfo {
formwork_notes?: string[];
formwork_reuse_missing?: string[];
shoring?: { applicable: boolean; reason: string; pending_types: string[] };
}
/** 성분이 어디로 가는지 — 화면에서도 보이게 한다. 규칙이 코드에만 있으면 잊힌다. */
const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공 합산",
@@ -297,6 +307,20 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
const skipped = warning("건너뛴 구조물", response.skipped_structures);
if (skipped) wrap.append(skipped);
// 거푸집 사용횟수 — 값이 아니라 **몇 회짜리인지**를 알려 주는 자리(품셈 1-7-1).
const info = unit as unknown as FormworkInfo;
const reuse = warning("거푸집 사용횟수", info.formwork_notes ?? []);
if (reuse) wrap.append(reuse);
const reuseMissing = warning("사용횟수 미확보", info.formwork_reuse_missing ?? []);
if (reuseMissing) wrap.append(reuseMissing);
if (info.shoring && !info.shoring.applicable) {
// 「없음」을 0 으로 적지 않는다 — 대상이 없는 것과 값이 0 인 것은 다르다.
const line = document.createElement("p");
line.className = "b08-grid__caption";
line.textContent = `동바리: 대상 없음 — ${info.shoring.reason.replace(/\*\*/g, "")}`;
wrap.append(line);
}
if (!unit.structures.length) {
const empty = document.createElement("p");
empty.className = "b08-quantity__message";
@@ -338,7 +362,8 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
tr.append(textCell(component.basis, "b08-grid__note"));
// ⚠ 식에서 나온 값과 실무 관측값이 한 표에 섞인다 — 어느 쪽인지 화면에서 보여야
// 나중에 「이 값이 왜 이런가」를 되짚을 수 있다.
tr.append(textCell(component.basis_kind === "observed" ? "실무 관측" : "치수 전개"));
const kind = component.basis_kind === "observed" ? "실무 관측" : "치수 전개";
tr.append(textCell(component.reuse_count ? `${kind} · ${component.reuse_count}` : kind));
body.append(tr);
}
}
@@ -0,0 +1,68 @@
{
"schema_version": "1.0",
"dataset_id": "formwork_reuse",
"effective_date": "2026-01-01",
"note": "거푸집 사용횟수 — **품셈 1-7-1 원문**이 구조물 종류별로 정해 둔 값이다. 관측값이 아니라 법이므로 실무값으로 갈음하지 않는다.",
"source": {
"doc": "산림사업 표준품셈 1-7-1 거푸집 사용",
"table_id": "F0040",
"quote": "2회 T형보, 난간, 특히 복잡한 구조의 교각, 교대, 수문관의 본체 등 복잡한 구조 / 3회 슬래브, 교대, 교각, 옹벽, 파라펫트, 날개벽 등 약간 복잡한 구조 / 4회 측구, 수로, 확대기초, 우물통 등 비교적 간단한 구조 / 6회 수문 또는 관의 기초, 호안 및 보호공의 기초 등 극히 간단한 구조"
},
"policy": {
"b08_delivers": "접촉 면적(㎡) + 사용횟수. **횟수별 재료 환산은 하지 않는다**.",
"b09_applies": "품셈 12-4 의 「사용횟수별 기준수량에 대한 비율(%)」은 일위대가 재료비에 걸린다. B08 이 여기서 곱하면 B09 와 겹쳐 두 번 준다.",
"unlisted_is_flagged": true
},
"reuse_by_class": [
{
"reuse_count": 2,
"class": "복잡한 구조",
"examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"]
},
{
"reuse_count": 3,
"class": "약간 복잡한 구조",
"examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"]
},
{
"reuse_count": 4,
"class": "비교적 간단한 구조",
"examples": ["측구", "수로", "확대기초", "우물통"]
},
{
"reuse_count": 6,
"class": "극히 간단한 구조",
"examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"]
}
],
"type_map": [
{
"type_id": "retaining_wall",
"reuse_count": 3,
"matched_example": "옹벽",
"note": "원문 3회 줄에 「옹벽」이 그대로 있음"
},
{
"type_id": "pipe_inlet_basin",
"reuse_count": 6,
"matched_example": "보호공 기초",
"note": "관보호공 집수정 — 원문 6회 줄의 「호안 및 보호공의 기초」에 해당. ⚠ 벽체까지 6회로 볼지는 확인 필요"
},
{
"type_id": "ford_pavement",
"reuse_count": null,
"note": "물넘이포장은 거푸집이 서지 않는 구조(면 포장) — 대상 아님"
}
],
"reuse_ratio_pct": {
"note": "품셈 12-4 「사용횟수별 기준수량에 대한 비율(%)」. **B09 일위대가가 쓰는 값**이며 B08 은 참고로만 싣는다 — 여기서 곱하면 이중계상.",
"table_id": "F0336",
"plywood": { "1": 100.0, "2": 57.0, "3": 46.1, "4": 40.1, "5": 37.1, "6": 34.7 },
"timber": { "1": 100.0, "2": 60.0, "3": 47.1, "4": 40.0, "5": 34.2, "6": 32.0 }
},
"shoring": {
"note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니다.",
"targets_pending": ["box_culvert", "ford_bridge"],
"why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
}
}
@@ -125,6 +125,16 @@
"work_item_code": "FP-13-04-02",
"master_name": "돌쌓기 > 메쌓기(장비)",
"note": "인력 시공이면 FP-13-04-01"
},
{
"type_id": "pipe_inlet_basin",
"work_item_code": "FP-12-15",
"master_name": "집수정"
},
{
"type_id": "ford_pavement",
"work_item_code": "FP-12-06",
"master_name": "콘크리트 포장(인력시공)"
}
],
"pending_user": {
@@ -132,14 +142,37 @@
"items": [
{
"group": "지장목제거",
"candidates": ["FP-04-01 수확베기", "FP-04-02 단목베기", "FP-04-03 위험목 베기"],
"candidates": [
"FP-04-01 수확베기",
"FP-04-02 단목베기",
"FP-04-03 위험목 베기"
],
"why": "품셈 4장은 벌목을 목적별로 가르는데 임도 지장목이 어느 쪽인지 원본이 말하지 않음"
},
{
"group": "흙깎기/측구터파기 암",
"candidates": ["FP-09-04 암절취(리핑)", "FP-09-05 발파암"],
"candidates": [
"FP-09-04 암절취(리핑)",
"FP-09-05 발파암"
],
"why": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
}
]
},
"composite": {
"note": "품셈에 **그 이름의 공종이 없어** 여러 공종을 묶어 일위대가로 세우는 자리. 코드 하나로 못 적으므로 묶음을 적어 둔다 — 빈 코드로 두면 「매핑을 못 찾은 줄」과 구별이 안 된다.",
"items": [
{
"type_id": "retaining_wall",
"parts": [
"FP-12-01 콘크리트 타설",
"FP-12-04 합판거푸집",
"FP-12-03 철근 현장가공 및 조림",
"FP-12-25 기초잡석"
],
"why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
}
]
}
}