feat(b08): 고정형 장 사유(㉰) · 규격 다름(㉱) — 전개 사유를 걷고 항목 사유를 띄움(남긴 전개 줄에 걸린 사유만 머리 달아 둠) · 항목 제목 H 와 장 제원 H 가 다르면 빨간 테두리 + 「규격 다름」 배지 + 「벽 수량은 항목 박힌 값 · 토공·사토 공제는 장 제원으로 셈」 사유(금액은 섬 · 막지 않음)

936be972 화면: 돌쌓기(찰) H=2.5 장에 H=2.0 호표 → 배지 「규격 다름 H2 ↔ H2.5」 · 테두리 · 사유 첫 줄 규격 다름 + 원문 시점 수량 · 뒷길이·돌 종류 사유 걷힘 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 17:27:53 +09:00
co-authored by Claude Opus 5
parent 31decd1558
commit 42909a01b9
3 changed files with 155 additions and 0 deletions
@@ -13,6 +13,7 @@
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -170,6 +171,49 @@ def _kept(rows: list[Any], template_rows: list[dict[str, Any]], name_of: Any) ->
]
#: ㉰ 고정형 장·구조물 사유 머리 — 남긴 전개 줄에 걸린 전개 사유 / ㉱ 규격 다름.
KEPT_NOTE_HEAD = "(남긴 전개 줄) "
MISMATCH_HEAD = "⚠ 규격 다름"
#: ㉱ 항목 제목에 적힌 높이 규격 「H=2.0m」 — 원문 제목의 값을 읽음(없으면 비교 안 함 · 추측 아님).
_SPEC_HEIGHT = re.compile(r"H\s*=\s*([0-9]+(?:\.[0-9]+)?)")
def _is_fixed(template: dict[str, Any]) -> bool:
"""고정형 — 식이 한 줄도 없음(명세 13장 · `StructureLibrary.item_kind` 와 같은 가름)."""
rows = template.get("rows") or []
return bool(rows) and not any(str(row.get("formula") or "").strip() for row in rows)
def fixed_notes(
template: dict[str, Any], engine_notes: list[str], height: Any
) -> tuple[list[str], dict[str, float] | None]:
"""㉰㉱ 고정형의 사유 — 항목 사유 + **남긴 전개 줄에 걸린** 전개 사유만 + 규격 다름(맨 앞).
전개 사유(뒷길이 표준·돌 종류…)는 고정형 줄과 안 맞아 거짓 사유 → 걷음. 남긴 줄(터파기·버림…)을
말하는 것만 머리를 달아 둠. 두 번 불려도(원단위 → 구조물도) 같은 값이 나오게 함.
"""
item = [part for part in str(template.get("note") or "").split(" · ") if part]
kept: list[str] = []
for note in engine_notes:
if note in item or note.startswith(MISMATCH_HEAD):
continue
if note.startswith(KEPT_NOTE_HEAD):
kept.append(note)
elif any(name in note for name in KEEP_ENGINE_COMPONENTS):
kept.append(KEPT_NOTE_HEAD + note)
notes = list(dict.fromkeys(item + kept))
found = _SPEC_HEIGHT.search(str(template.get("name") or ""))
if not found or height is None or abs(float(found.group(1)) - float(height)) < 1e-6:
return notes, None
item_h, sheet_h = float(found.group(1)), float(height)
notes.insert(
0,
f"{MISMATCH_HEAD} — 항목 H={found.group(1)}m ↔ 장 H={sheet_h:g}m: 벽 수량은 항목 박힌 값"
f"(H={found.group(1)}m) · 토공·사토 공제는 장 제원(H={sheet_h:g}m)으로 셈",
)
return notes, {"item_height_m": item_h, "sheet_height_m": sheet_h}
def _library_rows(
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
) -> list[dict[str, Any]]:
@@ -314,6 +358,8 @@ def replace_with_templates(
quantity.components = components + _kept(
quantity.components, body["rows"], lambda component: component.name
)
if _is_fixed(template):
quantity.notes, _mismatch = fixed_notes(template, quantity.notes, quantity.height_m)
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
quantity.library_item = str(template.get("name") or quantity.type_id)
@@ -383,6 +429,11 @@ def apply_templates(
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
if _is_fixed(template):
# ㉱ 규격 다름 — 금액은 서되 미확정 급으로 보임(막지 않음 · 사용자가 일부러 고른 것일 수 있음).
sheet["notes"], sheet["spec_mismatch"] = fixed_notes(
template, sheet.get("notes") or [], sheet.get("height_m")
)
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = {
"type_id": template.get("type_id"),
@@ -89,6 +89,8 @@ export interface StructureSheet extends StandardSheetSpec {
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
/** ㉱ 고정형 항목 규격(H)이 장 제원과 다름 — 금액은 서되 미확정 급(빨간 테두리·배지). */
spec_mismatch?: { item_height_m: number; sheet_height_m: number } | null;
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
var_candidates?: {
name: string;
@@ -260,6 +262,12 @@ function sheetBody(
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
const { item_height_m: item, sheet_height_m: own } = sheet.spec_mismatch;
head.append(el("span", "b08-unit__badge", `규격 다름 H${item} ↔ H${own}`));
main.classList.add("b08-unit__manual");
}
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
if (!sheet.rows.length) {
@@ -0,0 +1,96 @@
"""㉰ 고정형 장 사유 · ㉱ 규격 다름 (2026-09-14 브레인 판정).
㉰ 고정형을 가져온 장에 **전개 사유(뒷길이 표준·돌 종류…)가 그대로 남아** 고정형 줄과 안 맞음 = 거짓 사유 →
항목 사유(원문 시점 수량·가산 행)를 띄우고 전개 사유는 **남긴 전개 줄(토공·버림·기초잡석·채집석)에 걸린 것만** 둠.
㉱ H=2.0 호표를 H=2.5 벽에 쓰면 벽 수량은 박힌 값(H2.0) · 토공·사토 공제는 장 제원(H2.5) — 밑수가 갈림 →
미확정과 같은 급으로 보임(빨간 테두리·「규격 다름」 배지) · 금액은 섬 · 막지 않음.
"""
from __future__ import annotations
from pathlib import Path
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
ROOT = Path(__file__).resolve().parents[2]
HOPYO = {
"no": 6,
"source_code": "B00010",
"name": "기슭막이(깬잡석,찰쌓기 L3=45m)",
"spec": "H=2.0m, 채집",
"unit": "M",
"basis": [],
"contract_rows": 0,
"rows": [
{
"name": "깬잡석찰쌓기",
"spec": "L3=45",
"amount": 2.09,
"unit": "M2",
"remark": "",
"source_code": "",
},
{
"name": "공구손료",
"spec": "노무비의 %",
"amount": 2,
"unit": "%",
"remark": "",
"source_code": "",
},
],
}
NAMES = {"masonry_wet": "돌쌓기(찰)"}
def _sheet(height: float) -> dict:
wall = StructureInstance.model_validate(
{
"structure_id": "a",
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": height, "foundation": "기초유"},
}
).model_dump()
templates = {
"masonry_wet": {
**recipe_item(HOPYO, type_id="masonry_wet", file_name="a", project="p"),
"code": "AX-ST-0000abcd",
}
}
table = build_table([wall], NAMES, {}, {}, None, structure_templates=templates)
payload = build_standard_sheets(table, {})
apply_templates(payload, {}, templates)
return payload["sheets"][0]
def test_고정형_장은_항목_사유를_띄우고_남의_사유를_걷는다() -> None:
notes = " / ".join(_sheet(2.0)["notes"])
assert "원문 시점 수량" in notes and "가산 행 1줄" in notes
assert "돌 종류를 안 골라" not in notes and "뒷채움 폭" not in notes
def test_규격이_다르면_미확정_급으로_밑수_갈림을_알린다() -> None:
sheet = _sheet(2.5)
mismatch = sheet["spec_mismatch"]
assert (mismatch["item_height_m"], mismatch["sheet_height_m"]) == (2.0, 2.5)
assert "규격 다름" in sheet["notes"][0]
assert "토공·사토 공제는 장 제원(H=2.5m)" in sheet["notes"][0]
assert any(row["amount"] for row in sheet["rows"]) # 금액·수량은 막지 않음
def test_규격이_같으면_아무것도_안_뜬다() -> None:
sheet = _sheet(2.0)
assert not sheet.get("spec_mismatch")
assert not any("규격 다름" in note for note in sheet["notes"])
def test_화면이_규격_다름을_빨간_배지로() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text(encoding="utf-8")
assert "spec_mismatch" in ui and "규격 다름" in ui and "b08-unit__badge" in ui