fix(b08): 자재총괄 화면·인계를 한 벌로 — material_table_for 가 규준틀 재료까지 얹고 두 라우터가 같이 부름(화면 6줄·인계 9줄 갈림 닫음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 11:35:02 +09:00
co-authored by Claude Opus 5
parent 37791c5bfb
commit bd69aa4788
2 changed files with 54 additions and 29 deletions
+32 -29
View File
@@ -146,6 +146,33 @@ async def _section_modes(project_id: UUID) -> dict[float, str]:
return section_modes_from_designs(designs) return section_modes_from_designs(designs)
def material_table_for(
unit_table: dict[str, Any],
settings: dict[str, Any],
preparation_table: dict[str, Any] | None,
) -> dict[str, Any]:
"""자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정).
⚠ 규준틀 재료는 **준비공 개소가 선 뒤에야** 서므로 준비공 표를 받아 여기서 얹음. 종전엔 인계만
얹어 화면 6줄 · 인계 9줄로 갈림 → 화면에서 규준틀 재료의 관급구분·할증을 못 만짐.
⚠ 화면 쪽에 따로 복사하지 말 것 — 두 벌이 되면 또 갈림.
⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있음 — 그 사실이 줄 사유에 적힘.
"""
frame_rows = [
row
for row in ((preparation_table or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
return build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
@router.get("/{project_id}/quantity/material-summary") @router.get("/{project_id}/quantity/material-summary")
async def get_material_summary(project_id: UUID) -> JSONResponse: async def get_material_summary(project_id: UUID) -> JSONResponse:
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다. """구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
@@ -183,13 +210,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭. # 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
structure_templates=project_templates(project_root), structure_templates=project_templates(project_root),
) )
material_table = build_material_table( # 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음.
unit_table, earthwork = await _earthwork_tables(project_id)
supply_map=settings.get("material_supply") or {}, material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다. # 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table) handoff = build_handoff(unit_quantity_table=unit_table)
composite = [ composite = [
@@ -437,30 +460,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_formulas=settings.get("structure_formula_overrides"), structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root), structure_templates=project_templates(project_root),
) )
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다. # 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id) earthwork = await _earthwork_tables(project_id)
# 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함).
# 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다. material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
# ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다.
frame_rows = [
row
for row in ((earthwork.get("preparation") or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
handoff = build_handoff( handoff = build_handoff(
summary_table=earthwork.get("summary"), summary_table=earthwork.get("summary"),
@@ -85,6 +85,28 @@ def test_자재_축으로_간다() -> None:
assert all(row["destination"] == "material" for row in frame_material_rows(규준틀)) assert all(row["destination"] == "material" for row in frame_material_rows(규준틀))
def test_화면과_인계가_같은_한_벌을_부른다() -> None:
"""자재총괄 화면(6줄)·인계(9줄)가 갈리던 자리 — 규준틀 재료를 한 곳에서 얹음(2026-09-14).
⚠ 두 라우터가 이 함수를 부르는지까지 본다 — 한쪽이 옛 길로 돌아가면 다시 갈림.
"""
import inspect
from B08_Quantity import B08_Quantity_Router_Material as router
table = router.material_table_for(
{"structures": []},
{"material_supply": {"각재 50×50": "owner_supplied"}},
{"rows": 규준틀},
)
keys = {row["supply_key"]: row for row in table["rows"]}
assert set(keys) == {"각재 50×50", "판재 T12", ""}
assert keys["각재 50×50"]["supply"] == "owner_supplied" # 화면에서 고른 값이 먹음
for route in (router.get_material_summary, router.get_handoff):
source = inspect.getsource(route)
assert "material_table_for(" in source and "build_material_table(" not in source
def test_제안값이_실무_관측값과_같다() -> None: def test_제안값이_실무_관측값과_같다() -> None:
"""울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당).""" """울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당)."""
assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044 assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044