feat(B08): 구조물 집계표 열마다 사본 주인별 값 owners(145-3 이음)

- 열 owners = {사본 주인 id: 그 주인이 낸 값} — 주인 id = linked_snapshots 키(pipe@측점 · 구조물 id · {id}__{그룹})
- 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 · owners 합 = 열 계 · 기존 키 · 화면 그대로
- 새 test_145_3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-09 15:20:56 +09:00
co-authored by Claude Opus 5.5
parent 0d49a4c3c1
commit dbc9a32b72
2 changed files with 80 additions and 5 deletions
+34 -5
View File
@@ -22,6 +22,9 @@
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
머리 글 = M02 정의 이름(`구조물정의.json` types.name · 종류.{종류}.이름 — 사용자가 M02 에서 고침) ·
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}(145-3) — 주인 id = `linked_snapshots` 키
(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 칸을 낸 잇기 사본이 먼저 · 없으면 시설 본체 ·
합 = 열 계.
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
@@ -111,6 +114,21 @@ def _combo(item: Any) -> str | None:
return f"{item.get('layer')}|{item.get('owner') or ''}|{item['key']}"
class _Copy(str):
"""잇기 조합 id + 그 사본 주인 id(`linked_snapshots` 키 · `{구조물 id}__{그룹}`) — 145-3."""
copy: str
def _linked(combo: str | None, copy: str) -> str | None:
"""잇기 조합에 사본 주인 id 를 붙임 — 본체 조합(그냥 글)은 시설 id 가 주인."""
if combo is None:
return None
out = _Copy(combo)
out.copy = copy
return out
def one_pipe_length(project_root: str | Path) -> float | None:
"""1본 길이(m) — 프로젝트 층 구조물집계표 변수만(시스템 층으로 안 채움 · 143-2) · 빈칸 None."""
found = layers.read_template(layers.project_dir(project_root), "table", TABLE_NAME)
@@ -193,14 +211,19 @@ class _Sheet:
return
cid = f"pipe:{key}"
column = self.columns.setdefault(
cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort}
cid,
{"id": cid, "group": group, "label": label, "combos": [], "owners": {}, "_sort": sort},
)
for combo in combos:
if combo and combo not in column["combos"]:
column["combos"].append(combo)
column["combos"].append(str(combo))
row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}})
if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함
row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3)
# 사본 주인별 값(145-3 · 설계내역서가 대가 호표에 이음) — 잇기 사본이 먼저 · 없으면 본체
owner = next((c.copy for c in combos if isinstance(c, _Copy)), self.owner)
owners = column["owners"]
owners[owner] = round(owners.get(owner, 0.0) + value, 3)
def result(self) -> dict[str, Any]:
columns = sorted(self.columns.values(), key=lambda column: column["_sort"])
@@ -472,7 +495,11 @@ def _drain(
return
# 필수 컨테이너 잇기 — 정의 그룹에 없는 것(세월교)은 저장된 잇기에서
required = {"box_culvert": BOX_LINKS, "ford_bridge": FORD_LINKS}.get(type_id, PAVE_LINKS)
combos = {part: links.get(link) or _combo(group.get(link)) for part, link in required.items()}
combos = {
part: links.get(link)
or _linked(_combo(group.get(link)), f"{sheet.owner}{GROUP_JOIN}{link}")
for part, link in required.items()
}
if type_id == "ford_pavement":
_pavement(sheet, at, base, combos, names)
return
@@ -527,7 +554,7 @@ def pipe_summary(
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
if link:
entry["links"][link] = combo
entry["links"][link] = _linked(combo, item["복사본"])
else:
entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None}
@@ -552,7 +579,9 @@ def pipe_summary(
}
_drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length)
body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"]
body = links.get(body_link) or _combo(group.get(body_link))
body = links.get(body_link) or _linked(
_combo(group.get(body_link)), f"{owner}{GROUP_JOIN}{body_link}"
)
counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음)
no_box += type_id == "box_culvert" and counted and not box_length
if type_id == "ford_bridge":
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""145-3 이음 — B08 구조물 집계표 열마다 `owners` = {사본 주인 id: 그 주인이 낸 값}.
주인 id = `linked_snapshots` 키(`pipe@측점` · 구조물 id · `{id}__{그룹}`) · 열 owners 합 = 열 계 ·
칸을 낸 잇기 사본이 먼저(없으면 시설 본체).
"""
from __future__ import annotations
import pytest
from test_120_b08_drain_summary import out # noqa: F401 — 픽스처(관 밖 횡단배수 견본)
from test_143_1_b08_linked_snapshots import _run as _run_143
from test_143_1_b08_linked_snapshots import root # noqa: F401 — 픽스처(실제 사본 폴더)
from M02_MasterTemplete import M02_Item_Snapshot as snapshots
def _check_sums(result: dict, keys: set[str]) -> None:
for column in result["columns"]:
owners = column["owners"]
assert set(owners) <= keys, column["id"]
assert sum(owners.values()) == pytest.approx(result["total"].get(column["id"], 0.0))
def test_owners_합_열_계_주인은_사본_키(out): # noqa: F811
cols = {c["id"]: c for c in out["columns"]}
_check_sums(out, set(out["linked"]))
# 본체 칸 = 본체 잇기 사본(gboxbody) · 날개벽 = 유입 잇기 · 흙막이 = 유출 잇기
assert cols["pipe:box|body|2.0×2.0"]["owners"] == {"pipe@300.0__gboxbody": 9.6}
assert cols["pipe:box_culvert|wing|inlet"]["owners"] == {"pipe@300.0__gboxin": 1.0}
assert cols["pipe:revet|돌쌓기(메)|2.0"]["owners"] == {"pipe@300.0__gboxout": 10.0}
# 세월교 월류 폭 = 저장된 잇기(gford) · 관 공은 본체(사본 키 = 관 지점 id)
assert cols["pipe:ford|1000|2|width"]["owners"] == {"pipe@400.0__gford": 12.0}
assert cols["pipe:len|800"]["owners"] == {"pipe@300.0": 18.0}
# B05 구조물 = 구조물 id · 그룹 잇기 = {id}__{그룹}
assert cols["pipe:open_ditch"]["owners"] == {"s-ditch": 1.0}
assert cols["pipe:cross_underdrain"]["owners"] == {"s-ud__gcrossud": 1.0}
def test_실제_사본_폴더_키와_같다(root): # noqa: F811
result = _run_143(root)
linked = set(snapshots.linked_snapshots(root))
_check_sums(result, linked)
cols = {c["id"]: c for c in result["columns"]}
assert cols["pipe:len|800"]["owners"] == {"pipe@100.0": 16.0}
assert cols["pipe:len|1000"]["owners"] == {"pipe@500.0__gford": 18.0}