feat(B08): 유로폼 갈래 원문 자동 판정 + 못 채운 조각 사유를 구조로
「원문이 이미 정해 둠」 **네 번째** — 품셈 12-38-3 [주]④ 「보통: 측구, 수로,
옹벽, 일반적인 벽체, 박스」. 앞선 셋(거푸집 사용횟수 1-7-1 · 철근 갈래
12-3 [주]① · 밑수는 표 위 본문)과 같은 자리라 사람이 고를 칸을 안 만듦.
옹벽 → `FP-12-38#보통`, 근거 문구를 조각에 함께 실음.
- ⚠ 12-38-1 「사용횟수」는 1-7-1 과 **다른 자리**임. 그쪽은 유로폼(강재)의
잔존율(12회 25% · 25회 10%)로 임대료·손료 산정용이고, 1-7-1 은 소모성
거푸집 전용 횟수(옹벽 3회)임. 이름이 같아 하나로 이으면 조용히 틀림 —
데이터에 그 구분을 적고 시험으로 못 박음.
- 유로폼 자재는 12-38-2 가 10㎡당 패널 0.89매로 냄. B08 은 **면적(㎡)** 으로
보내고 매수 환산은 밑수를 아는 B09 가 함 — B08 이 환산하면 밑수가 두 벌.
`composite_not_ready` 를 `{code, reason}` 구조로 바꿈. 받는 쪽이 「단가 없음」과
「물량 없음」을 갈라야 함(기초잡석이 후자).
원단위 자체가 없으면 조각을 늘어놓지 않고 한 줄로 말함 — 같은 사유가 조각마다
다섯 번 반복되면 진짜 사유가 묻힘(화면에서 실제로 그렇게 보였음).
시험 추가 — 같은 공종코드가 지반만 달리해 여러 줄 오는 표본(도자 토사·리핑암).
실물에서 잡힌 모양이라 고정 자료에도 넣음.
검증 — 인계 59건 통과, 전체 600 passed, tsc 오류 0.
화면 그리기는 실제 모듈을 그대로 돌려 확인(사용자 프로젝트·DB 무접촉).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,8 @@ class FormworkTable:
|
||||
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)
|
||||
#: 유로폼 설치·해체 유형 — 품셈 12-38-3 [주]④ 가 시설 예시로 갈라 둔 것.
|
||||
euroform_type: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def for_type(self, type_id: str) -> dict[str, Any] | None:
|
||||
for row in self.type_map:
|
||||
@@ -72,6 +74,7 @@ def load_formwork_table(path: Path | None = None) -> FormworkTable:
|
||||
reuse_by_class=list(payload.get("reuse_by_class") or []),
|
||||
reuse_ratio_pct=payload.get("reuse_ratio_pct") or {},
|
||||
shoring=payload.get("shoring") or {},
|
||||
euroform_type=payload.get("euroform_type") or {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -165,6 +165,12 @@ def composite_quantities(
|
||||
⚠ **단위를 반드시 맞춘다.** 철근 단가는 `원/ton` 인데 원단위는 `㎏` 이다.
|
||||
안 맞추면 **1000배 틀린다** — 밑수에서 겪은 것과 같은 자리다.
|
||||
"""
|
||||
# ⚠ 원단위 자체가 없으면 조각을 늘어놓지 않는다 — 같은 사유가 다섯 번 반복되면
|
||||
# **진짜 사유가 묻힌다**(화면에서 실제로 그렇게 보였다). 한 줄로 말한다.
|
||||
if not (structure.get("components") or []):
|
||||
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
|
||||
return [], [{"code": None, "reason": note}]
|
||||
|
||||
amounts: dict[str, tuple[float, str]] = {}
|
||||
for component in structure.get("components") or []:
|
||||
name = str(component.get("name") or "").strip()
|
||||
@@ -197,6 +203,16 @@ def composite_quantities(
|
||||
"basis_kind": next(iter(kinds)) if len(kinds) == 1 else (sorted(kinds) or None),
|
||||
"from_components": sources,
|
||||
}
|
||||
if suffix == "euroform_type":
|
||||
kind, why = euroform_type(str(structure.get("type_id") or ""))
|
||||
entry["kind"] = kind
|
||||
entry["kind_basis"] = why
|
||||
if kind:
|
||||
entry["code"] = f"{spec.get('code')}#{kind}"
|
||||
else:
|
||||
entry["not_ready"] = True
|
||||
entry["why"] = why
|
||||
missing.append({"code": spec.get("code"), "reason": why})
|
||||
if suffix == "rebar_complexity":
|
||||
# 갈래는 원문이 정한다 — 화면·인계에 이름과 근거를 함께 실어 사람이 검증하게 한다.
|
||||
complexity, why = rebar_complexity(
|
||||
@@ -209,11 +225,12 @@ def composite_quantities(
|
||||
else:
|
||||
entry["not_ready"] = True
|
||||
entry["why"] = why
|
||||
missing.append(f"{spec.get('name')}: {why}")
|
||||
missing.append({"code": spec.get("code"), "reason": 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']}")
|
||||
# ⚠ 「단가 없음」과 「물량 없음」을 받는 쪽이 갈라야 하므로 **구조로** 낸다.
|
||||
missing.append({"code": spec.get("code"), "reason": entry["why"]})
|
||||
parts.append(entry)
|
||||
return parts, missing
|
||||
|
||||
@@ -279,6 +296,24 @@ def rebar_complexity(
|
||||
return None, f"품셈 12-3 [주]① 예시에 없는 구조({type_id} {form}) — 임의로 고르지 않음"
|
||||
|
||||
|
||||
def euroform_type(type_id: str, table: dict[str, Any] | None = None) -> tuple[str | None, str]:
|
||||
"""유로폼 설치·해체 유형 — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다.
|
||||
|
||||
「보통: 측구, 수로, **옹벽**, 일반적인 벽체, 박스」. 거푸집 사용횟수(1-7-1)·철근 갈래
|
||||
(12-3 [주]①)에 이어 **네 번째** 같은 자리다 — 미결로 올리기 전에 원문부터 뒤진다.
|
||||
|
||||
⚠ 12-38-1 「사용횟수」와 헷갈리지 말 것 — 그쪽은 유로폼(강재)의 **잔존율**이고
|
||||
1-7-1 의 소모성 거푸집 전용 횟수와도 다른 자리다.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_Formwork import load_formwork_table
|
||||
|
||||
found = table if table is not None else load_formwork_table().euroform_type
|
||||
for row in (found or {}).get("type_map") or []:
|
||||
if row.get("type_id") == type_id and row.get("class"):
|
||||
return str(row["class"]), f"품셈 12-38-3 [주]④ 「{row.get('matched')}」"
|
||||
return None, f"품셈 12-38-3 [주]④ 예시에 없는 시설({type_id}) — 임의로 고르지 않음"
|
||||
|
||||
|
||||
def _spec_detail(structure: dict[str, Any]) -> str:
|
||||
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
|
||||
parts: list[str] = []
|
||||
|
||||
@@ -109,7 +109,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
|
||||
"not_ready": row.get("composite_not_ready"),
|
||||
}
|
||||
for row in handoff["work_items"]
|
||||
if row.get("composite_parts")
|
||||
# 조각이 없어도(원단위 자체가 없어 못 세운 경우) 사유는 보여야 한다.
|
||||
if row.get("composite_parts") or row.get("composite_not_ready")
|
||||
]
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
||||
@@ -90,7 +90,8 @@ export interface MaterialResponse {
|
||||
composite?: {
|
||||
name: string;
|
||||
parts: CompositePart[];
|
||||
not_ready?: string[] | null;
|
||||
/** 못 채운 조각 — 「단가 없음」과 「물량 없음」을 가르려고 사유를 구조로 받는다. */
|
||||
not_ready?: { code: string | null; reason: string }[] | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -135,6 +136,11 @@ function headRow(labels: string[]): HTMLTableSectionElement {
|
||||
return head;
|
||||
}
|
||||
|
||||
/** 못 채운 조각의 사유를 사람이 읽는 줄로. 코드가 있으면 앞에 붙인다. */
|
||||
function reasons(items?: { code: string | null; reason: string }[] | null): string[] {
|
||||
return (items ?? []).map((item) => (item.code ? `${item.code} ${item.reason}` : item.reason));
|
||||
}
|
||||
|
||||
/** 못 정한 값 안내 — 목록이 있을 때만 뜬다. 매번 뜨면 잡음이 된다. */
|
||||
function warning(title: string, items: string[]): HTMLElement | null {
|
||||
if (!items.length) return null;
|
||||
@@ -344,6 +350,12 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
|
||||
// ⚠ 품셈에 그 이름의 공종이 없어 **여러 공종으로 나뉘어 서는** 구조물 — 무엇으로
|
||||
// 나뉘는지와 각 조각의 물량·갈래를 보인다. 코드만으로는 사람이 검증할 수 없다.
|
||||
for (const group of response.composite ?? []) {
|
||||
// 조각이 하나도 없으면 「묶음 공종 — 」 빈 줄이 남는다. 사유만 보이는 것이 낫다.
|
||||
if (!group.parts?.length) {
|
||||
const blocked = warning("⚠ 묶음을 못 세움", reasons(group.not_ready));
|
||||
if (blocked) wrap.append(blocked);
|
||||
continue;
|
||||
}
|
||||
const box = document.createElement("p");
|
||||
box.className = "b08-quantity__notice";
|
||||
const parts = group.parts.map((part) => {
|
||||
@@ -356,7 +368,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
|
||||
});
|
||||
box.textContent = `${group.name}: 묶음 공종 — ${parts.join(" · ")}`;
|
||||
wrap.append(box);
|
||||
const blocked = warning("⚠ 물량을 못 채운 조각", group.not_ready ?? []);
|
||||
const blocked = warning("⚠ 물량을 못 채운 조각", reasons(group.not_ready));
|
||||
if (blocked) wrap.append(blocked);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,22 +17,45 @@
|
||||
{
|
||||
"reuse_count": 2,
|
||||
"class": "복잡한 구조",
|
||||
"examples": ["T형보", "난간", "복잡한 교각", "교대", "수문관 본체"]
|
||||
"examples": [
|
||||
"T형보",
|
||||
"난간",
|
||||
"복잡한 교각",
|
||||
"교대",
|
||||
"수문관 본체"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reuse_count": 3,
|
||||
"class": "약간 복잡한 구조",
|
||||
"examples": ["슬래브", "교대", "교각", "옹벽", "파라펫트", "날개벽"]
|
||||
"examples": [
|
||||
"슬래브",
|
||||
"교대",
|
||||
"교각",
|
||||
"옹벽",
|
||||
"파라펫트",
|
||||
"날개벽"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reuse_count": 4,
|
||||
"class": "비교적 간단한 구조",
|
||||
"examples": ["측구", "수로", "확대기초", "우물통"]
|
||||
"examples": [
|
||||
"측구",
|
||||
"수로",
|
||||
"확대기초",
|
||||
"우물통"
|
||||
]
|
||||
},
|
||||
{
|
||||
"reuse_count": 6,
|
||||
"class": "극히 간단한 구조",
|
||||
"examples": ["수문 기초", "관의 기초", "호안 기초", "보호공 기초"]
|
||||
"examples": [
|
||||
"수문 기초",
|
||||
"관의 기초",
|
||||
"호안 기초",
|
||||
"보호공 기초"
|
||||
]
|
||||
}
|
||||
],
|
||||
"type_map": [
|
||||
@@ -57,12 +80,87 @@
|
||||
"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 }
|
||||
"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"],
|
||||
"targets_pending": [
|
||||
"box_culvert",
|
||||
"ford_bridge"
|
||||
],
|
||||
"why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
|
||||
},
|
||||
"euroform_type": {
|
||||
"note": "유로폼 설치·해체 유형(간단/보통/복잡) — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 거푸집 사용횟수(1-7-1)·철근 갈래(12-3 [주]①)와 같은 자리로, 사람이 고르는 값이 아니다.",
|
||||
"source": {
|
||||
"doc": "산림사업 표준품셈 12-38-3 설치 및 해체 [주]④",
|
||||
"table_id": "F0396",
|
||||
"quote": "복잡 — 토목: 교대, 날개벽 등 복잡하고 보강이 많은 구조 / 보통 — 측구, 수로, 옹벽, 일반적인 벽체, 박스 등 / 간단 — 수문 또는 관의 기초, 건축 매트기초 등 간단한 구조"
|
||||
},
|
||||
"classes": [
|
||||
{
|
||||
"key": "복잡",
|
||||
"examples": [
|
||||
"교대",
|
||||
"날개벽"
|
||||
],
|
||||
"daily_area_m2": 25
|
||||
},
|
||||
{
|
||||
"key": "보통",
|
||||
"examples": [
|
||||
"측구",
|
||||
"수로",
|
||||
"옹벽",
|
||||
"일반적인 벽체",
|
||||
"박스"
|
||||
],
|
||||
"daily_area_m2": 35
|
||||
},
|
||||
{
|
||||
"key": "간단",
|
||||
"examples": [
|
||||
"수문 기초",
|
||||
"관의 기초",
|
||||
"매트기초"
|
||||
],
|
||||
"daily_area_m2": 40
|
||||
}
|
||||
],
|
||||
"type_map": [
|
||||
{
|
||||
"type_id": "retaining_wall",
|
||||
"class": "보통",
|
||||
"matched": "옹벽"
|
||||
},
|
||||
{
|
||||
"type_id": "box_culvert",
|
||||
"class": "보통",
|
||||
"matched": "박스"
|
||||
},
|
||||
{
|
||||
"type_id": "pipe_inlet_basin",
|
||||
"class": "간단",
|
||||
"matched": "관의 기초",
|
||||
"note": "⚠ 집수정 벽체까지 간단으로 볼지는 확인 필요 — 거푸집 사용횟수와 같은 물음"
|
||||
}
|
||||
],
|
||||
"reuse_note": "⚠ 12-38-1 「사용횟수」는 **1-7-1 과 다른 자리다.** 1-7-1 은 합판거푸집처럼 **소모성 거푸집을 몇 번 쓰나**(옹벽 3회)이고, 12-38-1 은 유로폼(강재)의 **잔존율 기준**(12회 사용 잔존율 25 % · 25회 사용 잔존율 10 %)으로 **임대료·손료 산정용**이다. 하나로 잇지 않는다.",
|
||||
"material_unit_note": "유로폼 자재는 12-38-2 가 **10㎡당 패널 0.89매**로 낸다. B08 은 **접촉 면적(㎡)** 으로 보내고 매수 환산은 그 표의 밑수(10㎡)를 아는 B09 가 한다 — B08 이 환산하면 밑수를 두 벌로 들게 된다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@
|
||||
"unit": "㎡",
|
||||
"from_components": [
|
||||
"유로폼"
|
||||
]
|
||||
],
|
||||
"kind_suffix": "euroform_type"
|
||||
},
|
||||
{
|
||||
"code": "FP-12-03",
|
||||
|
||||
Reference in New Issue
Block a user