feat(B08): 묶음 조각 부위별 수량 + 철근 갈래 원문 자동 판정

옹벽 네 조각이 B09 에 다 서서 이제 물량을 실어 보냄.

- `composite_parts` 에 조각마다 수량·단위·근거(`basis_kind`)를 실음. 코드만
  보내면 받는 쪽이 상세 줄을 못 세움. 조각은 **원단위 성분 이름으로** 찾고
  못 찾으면 0 이 아니라 사유와 함께 `not_ready` 로 남김.
- ⚠ 철근은 ㎏ → ton 환산. 단가가 원/ton 인데 원단위는 ㎏ 이라 안 맞추면
  1000배 틀림. D13 13.45 + D16 30.42 = 43.87㎏/m × 10m = 0.4387ton.
- 철근 갈래는 **품셈 12-3 [주]① 원문**이 정함 — 「간단: 중력식 옹벽 /
  보통: 반중력식 옹벽 / 복잡: 부벽식 옹벽」. 거푸집 사용횟수(1-7-1)와 같은
  자리라 사람이 고를 칸을 만들지 않음. 캔틸레버식은 예시에 없어 「보통」으로
  때우지 않고 미확보로 드러냄.
- 전개 결과에 저장 제원(`options`)을 실어 뒤 단계가 형식을 읽게 함. 치수를
  다시 쓰라는 뜻이 아니라 읽으라는 것 — 치수 정본은 여전히 하나.
- 화면(구조물 원단위 탭)에 묶음 조각과 갈래를 보임.

⚠ 옛 시험 하나를 계약 변경에 맞춰 옮김 — `composite_not_ready` 가
「일위대가가 아직 안 선 공종」에서 「물량을 못 채운 조각」으로 뜻이 바뀜.

검증 — 인계 54건 통과, 전체 594 passed, tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:27:47 +09:00
co-authored by Claude Opus 5
parent 01578eaed6
commit 92e0ddca01
6 changed files with 309 additions and 13 deletions
+117 -3
View File
@@ -50,6 +50,11 @@ from common_util.common_util_quantity_spread import spread_by_unit
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
DATASET_PREFIX = "work_item_mapping_"
#: 철근 갈래표 — **품셈 12-3 [주]① 원문**이 구조물 예시로 갈라 둔 것이라 사람이 고르는 값이
#: 아니다(거푸집 사용횟수 1-7-1 과 같은 자리). 원문 예시에 안 걸리면 지어내지 않는다.
REBAR_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_rebar"
REBAR_PREFIX = "rebar_complexity_"
#: 줄이 어디서 왔나 — 되짚을 때 쓴다.
ORIGIN_EARTHWORK = "earthwork"
ORIGIN_STRUCTURE = "structure"
@@ -92,6 +97,7 @@ class WorkItemMapping:
pending_user: dict[str, Any] = field(default_factory=dict)
composite: dict[str, Any] = field(default_factory=dict)
concrete_placing: dict[str, Any] = field(default_factory=dict)
unit_conversion: dict[str, Any] = field(default_factory=dict)
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
@@ -143,9 +149,75 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
pending_user=payload.get("pending_user") or {},
composite=payload.get("composite") or {},
concrete_placing=payload.get("concrete_placing") or {},
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
)
def composite_quantities(
structure: dict[str, Any],
composite: dict[str, Any],
mapping: WorkItemMapping,
) -> tuple[list[dict[str, Any]], list[str]]:
"""묶음 조각마다 **부위별 수량**을 채운다. (조각 목록, 못 채운 사유).
⚠ 조각은 **원단위 성분 이름으로** 찾는다. 이름이 어긋나면 물량이 조용히 0 이 되므로
못 찾으면 그 조각을 `not_ready` 로 남기고 사유를 적는다 — 0 을 적지 않는다.
⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다.
안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다.
"""
amounts: dict[str, tuple[float, str]] = {}
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
parts: list[dict[str, Any]] = []
missing: list[str] = []
for spec in composite.get("parts") or []:
if not isinstance(spec, dict): # 옛 모양(코드 문자열)은 그대로 흘린다
parts.append({"code": str(spec)})
continue
sources = list(spec.get("from_components") or [])
found = [name for name in sources if name in amounts]
total = sum(amounts[name][0] for name in found)
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
total *= kg_to_ton
kinds = {
component.get("basis_kind")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
}
suffix = spec.get("kind_suffix")
entry: dict[str, Any] = {
"code": spec.get("code"),
"name": spec.get("name"),
"unit": spec.get("unit"),
"quantity": total if found else None,
# 조각마다 근거를 단다 — 치수 전개와 관측값이 한 묶음에 섞인다.
"basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None),
"from_components": sources,
}
if suffix == "rebar_complexity":
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
complexity, why = rebar_complexity(
str(structure.get("type_id") or ""), structure.get("options") or {}
)
entry["kind"] = complexity
entry["kind_basis"] = why
if complexity:
entry["code"] = f"{spec.get('code')}#{complexity}"
else:
entry["not_ready"] = True
entry["why"] = why
missing.append(f"{spec.get('name')}: {why}")
if spec.get("not_ready") or not found:
entry["not_ready"] = True
entry["why"] = str(spec.get("why") or "원단위에 해당 성분이 없음")
missing.append(f"{spec.get('name') or spec.get('code')}: {entry['why']}")
parts.append(entry)
return parts, missing
def structure_kind(structure: dict[str, Any]) -> str:
"""콘크리트 구조물 종류 — **원단위에 철근이 있나 없나로 판정한다.**
@@ -169,6 +241,44 @@ def placing_code(mapping: WorkItemMapping, method: str | None) -> tuple[str | No
return codes.get(default), True
def load_rebar_table(path: Path | None = None) -> dict[str, Any]:
"""철근 갈래표를 읽는다. 파일이 없으면 **빈 표** — 전부 「갈래 미확보」로 드러난다."""
target = path
if target is None:
files = sorted(REBAR_DIR.glob(REBAR_PREFIX + "*.json")) if REBAR_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def rebar_complexity(
type_id: str, options: dict[str, Any], table: dict[str, Any] | None = None
) -> tuple[str | None, str]:
"""(철근 갈래, 근거). **원문 예시에 걸리는 것만** 정하고 안 걸리면 `(None, 사유)`.
품셈 12-3 [주]① — 「간단: 측구·간단한 기초·**중력식 옹벽** / 보통: 수문·**반중력식 옹벽**·
교대 / 복잡: 교량 슬래브·암거·우물통·**부벽식 옹벽** / 매우복잡: 구주식 교대·교각…」.
사람에게 묻지 않는다 — **판정할 수 있는 것을 물으면 그것이 곧 미결이 된다.**
"""
found = table if table is not None else load_rebar_table()
form = options.get("form")
fallback: dict[str, Any] | None = None
for row in found.get("form_map") or []:
if row.get("type_id") != type_id:
continue
if "form" not in row:
fallback = row
continue
if row.get("form") == form:
if row.get("class"):
return str(row["class"]), f"품셈 12-3 [주]① 「{row.get('matched')}"
return None, str(row.get("why") or "원문 예시에 없음")
if fallback and fallback.get("class"):
return str(fallback["class"]), f"품셈 12-3 [주]① 「{fallback.get('matched')}"
return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음"
def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
parts: list[str] = []
@@ -307,6 +417,10 @@ def _structure_rows(
code = entry.get("work_item_code")
composite = mapping.composite_for(type_id) if code is None else None
kind = structure_kind(structure) if composite else None
parts: list[dict[str, Any]] | None = None
parts_missing: list[str] = []
if composite:
parts, parts_missing = composite_quantities(structure, composite, mapping)
if code is None and composite is None:
unmatched.append(f"구조물({type_id})")
length = float(structure.get("length_m") or 0.0)
@@ -328,11 +442,11 @@ def _structure_rows(
"station_to": structure.get("end_m"),
"spec_detail": _spec_detail(structure),
# 품셈에 그 이름의 공종이 없어 여러 공종을 묶는 자리 — 빈 코드와 구별한다.
"composite_parts": (composite or {}).get("parts"),
"composite_parts": parts,
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind,
# ⚠ 아직 일위대가가 안 선 공종 — 지금 세우면 절반짜리가 된다.
"composite_not_ready": (composite or {}).get("not_ready"),
# ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다.
"composite_not_ready": parts_missing or None,
"in_bill": True,
"in_bill_reason": (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE,
@@ -127,6 +127,9 @@ class StructureQuantity:
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
start_m: float | None = None
end_m: float | None = None
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
@@ -353,6 +356,7 @@ def expand(
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,
options=dict(options),
)
expander = EXPANDERS.get(type_id)
if expander is None:
@@ -420,6 +424,8 @@ def build_table(
"height_m": item.height_m,
"start_m": item.start_m,
"end_m": item.end_m,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
"notes": item.notes,
"components": [
{
@@ -100,10 +100,22 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
unit_table,
supply_map=settings.get("material_supply") or {},
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
{
"name": row["name"],
"parts": row["composite_parts"],
"not_ready": row.get("composite_not_ready"),
}
for row in handoff["work_items"]
if row.get("composite_parts")
]
return JSONResponse(
content={
"unit_quantity": unit_table,
"material": material_table,
"composite": composite,
"skipped_structures": skipped,
"structure_count": len(structures),
}
@@ -61,6 +61,20 @@ export interface UnitQuantityStructure {
}[];
}
/** 묶음으로 서는 구조물의 조각. 품셈에 그 이름의 공종이 없어 여러 공종으로 나뉜다. */
export interface CompositePart {
code: string | null;
name?: string;
unit?: string;
quantity: number | null;
basis_kind?: string | string[] | null;
/** 철근 갈래(간단/보통/복잡/매우복잡) — 품셈 원문이 정한다. */
kind?: string | null;
kind_basis?: string;
not_ready?: boolean;
why?: string;
}
export interface MaterialResponse {
unit_quantity: {
structures: UnitQuantityStructure[];
@@ -72,6 +86,12 @@ export interface MaterialResponse {
material: MaterialTable;
skipped_structures: string[];
structure_count: number;
/** 인계에서 온 묶음 조각 — 화면이 「무엇으로 나뉘어 서는지」를 보인다. */
composite?: {
name: string;
parts: CompositePart[];
not_ready?: string[] | null;
}[];
}
/** 거푸집·동바리 안내에 쓰는 값. */
@@ -321,6 +341,25 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
wrap.append(line);
}
// ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로
// 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다.
for (const group of response.composite ?? []) {
const box = document.createElement("p");
box.className = "b08-quantity__notice";
const parts = group.parts.map((part) => {
const kind = part.kind ? `#${part.kind}` : "";
const amount =
part.quantity === null || part.quantity === undefined
? ""
: `${num(part.quantity, 3)}${part.unit ?? ""}`;
return `${part.name ?? part.code}${kind} ${amount}${part.not_ready ? " ⚠" : ""}`;
});
box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`;
wrap.append(box);
const blocked = warning("⚠ 물량을 못 채운 조각", group.not_ready ?? []);
if (blocked) wrap.append(blocked);
}
if (!unit.structures.length) {
const empty = document.createElement("p");
empty.className = "b08-quantity__message";
@@ -0,0 +1,70 @@
{
"schema_version": "1.0",
"dataset_id": "rebar_complexity",
"effective_date": "2026-01-01",
"note": "철근 가공·조립 갈래(간단/보통/복잡/매우복잡) — **품셈 12-3 [주]① 원문**이 구조물을 예시로 갈라 둔다. 사람이 고르는 값이 아니라 법으로 정해지는 값이다(거푸집 사용횟수 1-7-1 과 같은 자리).",
"source": {
"doc": "산림사업 표준품셈 12-3 철근 현장가공 및 조립 [주] ①",
"table_id": "F0335",
"quote": "간단한 것이란 측구, 간단한 기초 및 중력식 옹벽 등을 말하며, 보통의 것이란 수문, 반중력식 옹벽 및 교대 등을 말하고, 복잡한 것이란 교량의 슬래브, 암거, 우물통 부벽식 옹벽 등을 말하며, 매우 복잡한 것이란 구주식(기둥형) 교대, 교각, 지하철, 터널"
},
"policy": {
"auto_decided": true,
"unlisted_is_flagged": true,
"note": "원문 예시에 걸리는 것만 정한다. 안 걸리면 「갈래 미확보」로 드러내고 임의로 고르지 않는다."
},
"classes": [
{ "key": "간단", "examples": ["측구", "간단한 기초", "중력식 옹벽"] },
{ "key": "보통", "examples": ["수문", "반중력식 옹벽", "교대"] },
{ "key": "복잡", "examples": ["교량 슬래브", "암거", "우물통", "부벽식 옹벽"] },
{ "key": "매우복잡", "examples": ["구주식(기둥형) 교대", "교각", "지하철", "터널"] }
],
"form_map": [
{
"type_id": "retaining_wall",
"form": "중력식",
"class": "간단",
"matched": "중력식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "반중력식",
"class": "보통",
"matched": "반중력식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "부벽식",
"class": "복잡",
"matched": "부벽식 옹벽"
},
{
"type_id": "retaining_wall",
"form": "캔틸레버식",
"class": null,
"why": "원문 [주]① 예시에 캔틸레버식 옹벽이 없음 — 임의로 고르지 않고 미확보로 드러냄"
},
{
"type_id": "box_culvert",
"class": "복잡",
"matched": "암거"
},
{
"type_id": "pipe_inlet_basin",
"class": "간단",
"matched": "간단한 기초",
"note": "관보호공 집수정 — 원문의 「간단한 기초」에 해당. ⚠ 벽체까지 간단으로 볼지는 확인 필요"
}
],
"price_hint_krw_per_ton": {
"note": "⚠ **표시 전용.** 갈래를 자동으로 정하더라도 화면에 갈래 이름과 차이를 보여야 사람이 검증할 수 있다. B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).",
"computed_by": "B09",
"computed_on": "2026-09-07",
"values": {
"간단": 919146.7,
"보통": 1032497.4,
"복잡": 1143569.7,
"매우복잡": 1278375.3
}
}
}
@@ -142,12 +142,19 @@
"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": "설계자가 넣는 암 갈래 이름(풍화암·연암·보통암·경암)이 리핑이냐 발파냐를 말하지 않음. 갈래마다 시공법을 지정하는 칸이 필요함"
}
]
@@ -158,18 +165,62 @@
{
"type_id": "retaining_wall",
"parts": [
"FP-12-01-01#철근구조물",
"FP-12-04 합판거푸집",
"FP-12-03 철근 현장가공 및 조림",
"FP-12-25 기초잡석"
{
"code": "FP-12-01-01",
"name": "콘크리트 타설",
"unit": "㎥",
"from_components": [
"콘크리트",
"버림콘크리트"
],
"kind_suffix": "concrete_placing"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": [
"합판거푸집"
]
},
{
"code": "FP-12-38",
"name": "유로폼",
"unit": "㎡",
"from_components": [
"유로폼"
]
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": [
"이형철근 D13",
"이형철근 D16"
],
"unit_from": "kg",
"kind_suffix": "rebar_complexity"
},
{
"code": "FP-12-25",
"name": "기초잡석",
"unit": "㎥",
"from_components": [],
"not_ready": true,
"why": "관측 원단위에 기초잡석 물량이 없음 — 울진 라이브러리 반중력식 H=2.0 항목에 그 줄이 없다"
}
],
"why": "품셈 12장에 「옹벽」 공종이 없음. 실무 내역은 「반중력식옹벽 H=2.0」 한 줄이고 그 일위대가가 위 공종을 묶음.",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김",
"placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.",
"not_ready": ["FP-12-03", "FP-12-25"],
"not_ready_why": "B09 일위대가가 아직 안 섬 — 지금 세우면 절반짜리가 됨(2026-09-07 조율 창)"
"parts_note": "조각마다 **어느 성분에서 나오는지**(`from_components`)를 적는다. 이름을 바꾸면 물량이 조용히 0 이 되므로 원단위 성분 이름과 정확히 같아야 하고, 못 찾으면 드러낸다. `unit_from` 이 있으면 그 단위에서 목표 단위로 환산한다 — **철근은 ㎏ → ton (÷1000)**. ⚠ 단위를 안 맞추면 1000배 틀린다."
}
]
],
"unit_conversion": {
"note": "조각 단위 환산. 단가의 단위(원/ton)와 원단위의 단위(㎏)가 달라 반드시 맞춰야 한다.",
"kg_to_ton": 0.001
}
},
"concrete_placing": {
"note": "콘크리트 타설은 **타설 방식 × 구조물 종류**로 갈린다. 방식은 설계 판단이라 프로젝트 설정(`quantity.concrete_placing_method`)이 고르고, 종류는 **원단위에 철근이 있나 없나로 자동 판정**한다 — 사람이 고르는 값이 아니다(2026-09-07 3자 확정).",
@@ -180,7 +231,11 @@
},
"default_method": "ready_mixed",
"default_is_provisional": true,
"structure_kinds": ["무근구조물", "철근구조물", "소형구조물"],
"structure_kinds": [
"무근구조물",
"철근구조물",
"소형구조물"
],
"kind_rule": "원단위 성분에 철근(이형철근·원형철근)이 있으면 철근구조물, 없으면 무근구조물. 소형구조물 판정 기준은 미확보.",
"price_hint_krw_per_m3": {
"note": "⚠ **표시 전용.** 사용자가 타설 방식을 고를 때 「정하면 얼마나 달라지는지」를 보이려고 둔 값이며 B08 의 어떤 계산에도 들어가지 않는다(금액은 B09 몫 — 8-2 경계). 값은 B09 가 2026-09-07 에 낸 철근구조물 기준 단가이고, 요율·노임이 바뀌면 어긋난다 — 화면이 「참고」임을 함께 적는다.",