feat(b08): 구조물 집계표 커플링 = 좌측 패널 1본 길이로 바로 다시 셈 · 응답에 관마다 길이 pipes(122-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-08 18:43:59 +09:00
co-authored by Claude Opus 5.5
parent 6a9506f1fb
commit a42fcd920e
3 changed files with 41 additions and 6 deletions
+5 -1
View File
@@ -13,6 +13,8 @@
머리 글 = M02 정의 이름(`구조물정의.json` 종류.pipe.이름 — 사용자가 M02 에서 고침) ·
열마다 `combos` = 그 값을 낸 컨테이너에 이은 조합 id(`{층}|{주인}|{키}` · 95-3 모달 · 맨 윗 머리).
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
"""
from __future__ import annotations
@@ -181,6 +183,7 @@ def pipe_summary(
entry["base"] = item
sheet = _Sheet()
pipes: list[dict[str, Any]] = []
no_length = no_basin = 0
soil_name = names.get(f"outlet.structure={SOIL}", SOIL)
for owner, entry in by_owner.items():
@@ -213,6 +216,7 @@ def pipe_summary(
couplings(length, one_m),
pipe_combos,
)
pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length})
shape = fill._nearest(basins, at)
no_basin += shape is None
if shape:
@@ -288,7 +292,7 @@ def pipe_summary(
notes.append(f"집수정 모양 기록 없음 {no_basin}곳 — B06 횡단 [저장] 뒤 채워짐")
if one_m is None:
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
return {**out, "notes": notes, "one_length_m": one_m}
return {**out, "notes": notes, "one_length_m": one_m, "pipes": pipes}
def _summary(root: Path, designs: list[dict[str, Any]]) -> dict[str, Any]:
+19 -5
View File
@@ -8,6 +8,7 @@
* 배관(횡단배수관) 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 다른 구조물 열은 조합 그대로
* 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`)
* 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -32,6 +33,8 @@ import {
type PlacedItem,
type StationCombo,
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -65,6 +68,8 @@ interface PipeSummary {
rows: Array<{ chainage_m: number; values: Record<string, number> }>;
total: Record<string, number>;
notes: string[];
one_length_m: number | null;
pipes: PipeLength[];
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
@@ -204,20 +209,29 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
...placed.filter((point) => (point.facility ?? "pipe") !== "pipe"),
];
const columns = summaryColumns(others, describe);
const summary = typeof pipe === "string" ? null : pipe;
const saved = typeof pipe === "string" ? null : pipe;
const live = (): PipeSummary | null =>
saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m));
const summary = live();
const parts: HTMLElement[] = [];
if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`));
for (const text of summary?.notes ?? []) parts.push(note(text));
if (!columns.length && !summary?.columns.length)
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
parts.push(
const draw = (): HTMLElement =>
stations.length
? buildTable(stations, columns, summary, comboPath, interval, (station, label) =>
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
openSheets(projectId, label, combosAt(things, station), m02, describe),
)
: note(L("B08_Quantity_Summary_NoStation")),
);
: note(L("B08_Quantity_Summary_NoStation"));
let table = draw();
parts.push(table);
host.replaceChildren(...parts);
onOptionsChange(host, () => {
const next = draw();
table.replaceWith(next);
table = next;
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
host.replaceChildren(note(`${L("B08_Quantity_Summary_Failed")} — ${reason}`));
@@ -210,6 +210,23 @@ def test_1본_길이는_프로젝트_층_변수(root, tmp_path, monkeypatch):
assert summary.one_pipe_length(root) is None
def test_관마다_길이_칸_화면이_1본_바꿔_다시_셈(root):
# 122 — 응답 `pipes` = 관마다 길이 · 커플링 열(화면 recountCouplings 가 같은 식으로 셈)
six, eight = _run(root, 6.0), _run(root, 8.0)
assert six["pipes"] == eight["pipes"]
assert [(round(p["chainage_m"]), p["column"], p["length_m"]) for p in six["pipes"]] == [
(1433, "pipe:cp|1200", 22),
(1567, "pipe:cp|800", 18),
(1680, "pipe:cp|800", 16),
(1790, "pipe:cp|800", None),
]
for out, one in ((six, 6.0), (eight, 8.0)):
total = sum(
summary.couplings(p["length_m"], one) or 0 for p in out["pipes"] if "800" in p["column"]
)
assert out["total"]["pipe:cp|800"] == total
def test_라우터_등록():
text = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8")
assert "app.include_router(b08_summary_router, dependencies=protected_with_company)" in text