feat(B08): 미연결 시설도 구조물 집계표 줄 — 값 빈칸 · 「미연결」 표시 · 응답 unlinked(154-1)

- 서버 pipe_summary 응답 unlinked = 셈한 칸에 사본 없는 시설 · 칸 전부 + 연결 여부(몸 = Summary_Sheet)
- 같은 측점 박스 + 관 = 종류로 가름 · 세월교 필수 잇기(gford · gfordin · gfordout) 칸 포함
- 화면 = 그 측점 줄 「· 미연결」 · 알림 글 「「미연결」 줄을 눌러 조합 연결」

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 19:04:10 +09:00
co-authored by Claude Opus 5.5
parent 09b3b50efc
commit 3c510d27f4
6 changed files with 148 additions and 17 deletions
+17 -9
View File
@@ -28,6 +28,7 @@
설계 = B08 토공과 같은 길(확정 노선 먼저) · 관 · 컨테이너 줄 = M02 채움 `collect_items` 재사용.
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
`unlinked` = 셈한 칸에 사본 없는 시설 — 칸 전부 + 연결 여부(줄 「미연결」 · 바로 연결 · PLAN 154).
"""
from __future__ import annotations
@@ -47,7 +48,7 @@ from B06_Section.B06_Section_Repository import (
get_workflow_route_context,
)
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Summary_Sheet import _linked, _Sheet
from B08_Quantity.B08_Quantity_Summary_Sheet import _linked, _Sheet, unlinked_facilities
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_summary_item import GROUP_JOIN
from config.config_db import get_db_pool
@@ -484,11 +485,13 @@ def pipe_summary(
box_lengths = _station_values(designs, "box_length_m")
ford_lengths = _station_values(designs, "ford_pipe_length_m")
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
others: dict[str, bool] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
# 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
others: dict[tuple[str, str], bool] = {}
for item in items:
owner, _, link = item["복사본"].partition(GROUP_JOIN)
if item["type_id"] not in DRAIN_TYPES:
others[owner] = others.get(owner, False) or item["복사본"] in linked
key = (item["type_id"], owner)
others[key] = others.get(key, False) or item["복사본"] in linked
continue
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
@@ -506,7 +509,7 @@ def pipe_summary(
if base is None:
continue
links = entry["links"]
sheet.owner = owner
sheet.owner, sheet.kind = owner, type_id
point = raw.get((type_id, owner))
options = dict(point.options or {}) if point else {}
at = float(base["chainage_m"])
@@ -589,11 +592,9 @@ def pipe_summary(
out = sheet.result()
notes = []
unlinked = len(sheet.unlinked) + sum(not found for found in others.values())
if unlinked:
notes.append(
f"조합 사본 없는 시설 {unlinked}곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]"
)
missing = sheet.unlinked | {key for key, found in others.items() if not found}
if missing:
notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 「미연결」 줄을 눌러 조합 연결")
if no_length:
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
if no_basin:
@@ -611,6 +612,13 @@ def pipe_summary(
"types": list(DRAIN_TYPES),
"pipes": pipes,
"linked": linked,
"unlinked": unlinked_facilities( # 154-1 · 줄 누름 → 바로 연결(154-2)
items,
linked,
missing,
{t["type_id"]: t["name"] for t in kinds.get("types") or []},
{key: point.group_items or {} for key, point in raw.items()},
),
}
+27
View File
@@ -5,6 +5,7 @@
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
* 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1)
* 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1)
*
* 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다.
* 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림.
@@ -56,6 +57,32 @@ export function linkedOnly<T extends PlacedItem>(
return out;
}
/** 미연결 시설 한 칸(서버 `unlinked_facilities` · 154) — 본체 `group` = null. */
export interface UnlinkedSlot {
/** 사본 id(본체 = 시설 id · 그룹 = `{id}__{그룹}`) */
copy: string;
group: string | null;
item: SummaryItem | null;
linked: boolean;
}
/** 미연결 시설 — 칸 전부 + 연결 여부 · `facility` = 관 지점 종류(구조물은 null). */
export interface UnlinkedFacility {
id: string;
type_id: string;
name: string;
facility: string | null;
chainage_m: number;
slots: UnlinkedSlot[];
}
/** 측점(1cm 안 같은 측점)의 미연결 시설. */
export const unlinkedAt = (
facilities: readonly UnlinkedFacility[],
station: number,
): UnlinkedFacility[] =>
facilities.filter((f) => Math.round(f.chainage_m * 100) === Math.round(station * 100));
/** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */
export interface ComboLabel {
group: string;
+59 -2
View File
@@ -7,6 +7,8 @@ from __future__ import annotations
from typing import Any
from common_util.common_util_summary_item import GROUP_JOIN
class _Copy(str):
"""잇기 조합 id + 그 사본 주인 id(`linked_snapshots` 키 · `{구조물 id}__{그룹}`) — 145-3."""
@@ -30,7 +32,8 @@ class _Sheet:
self.columns: dict[str, dict[str, Any]] = {}
self.rows: dict[int, dict[str, Any]] = {}
self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1)
self.unlinked: set[str] = set()
self.kind = "" # 그 시설 종류 — 같은 측점 박스 + 관은 주인 id 가 같음(154-1)
self.unlinked: set[tuple[str, str]] = set()
self.sources: list[list[Any]] = [] # 칸마다 [열, 값, 시설, 조합] — 자재 재연결(143-4)
def add(
@@ -44,7 +47,7 @@ class _Sheet:
combos: list[str | None],
) -> None:
if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1)
self.unlinked.add(self.owner)
self.unlinked.add((self.kind, self.owner))
return
cid = f"pipe:{key}"
column = self.columns.setdefault(
@@ -76,3 +79,57 @@ class _Sheet:
"total": total,
"sources": self.sources,
}
#: 옵션 그룹 정의 밖 필수 컨테이너 잇기(`design_structure_ford.ts` FORD_SPEC `link` · 제목)
FIXED_SLOTS = {
"ford_bridge": (("gford", "세월교"), ("gfordin", "유입 구조"), ("gfordout", "유출 구조"))
}
def unlinked_facilities(
items: list[dict[str, Any]],
linked: dict[str, Any],
missing: set[tuple[str, str]],
type_names: dict[str, str],
stored: dict[tuple[str, str], dict[str, Any]],
) -> list[dict[str, Any]]:
"""미연결 시설(셈한 칸 기준 `missing` · 154-1) — 시설마다 칸 전부 + 연결 여부(154-2 바로 연결).
`{id, type_id, name, facility(관 지점만), chainage_m, slots[{copy, group, item, linked}]}` ·
본체 칸 `group` = None · `stored` = 시설마다 저장된 `group_items`(필수 컨테이너 잇기 칸)."""
out: dict[tuple[str, str], dict[str, Any]] = {}
for item in items:
owner = item["복사본"].partition(GROUP_JOIN)[0]
key = (item["type_id"], owner)
if key not in missing:
continue
entry = out.setdefault(
key,
{
"id": owner,
"type_id": item["type_id"],
"name": type_names.get(item["type_id"], item["type_id"]),
"facility": item["type_id"] if owner.startswith("pipe@") else None,
"chainage_m": float(item["chainage_m"]),
"slots": [],
},
)
entry["slots"].append(
{
"copy": item["복사본"],
"group": item.get("그룹") if item["복사본"] != owner else None,
"item": item.get("summary_item"),
"linked": item["복사본"] in linked,
}
)
for (type_id, owner), entry in out.items():
have = {slot["copy"] for slot in entry["slots"]}
links = stored.get((type_id, owner)) or {}
for gid, title in FIXED_SLOTS.get(type_id, ()):
copy = f"{owner}{GROUP_JOIN}{gid}"
if copy not in have:
entry["slots"].append(
{"copy": copy, "group": title, "item": links.get(gid), "linked": copy in linked}
)
return sorted(out.values(), key=lambda entry: (entry["chainage_m"], entry["id"]))
+18 -5
View File
@@ -5,7 +5,8 @@
*
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`)
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점
* 줄만 「· 미연결」(값 빈칸 · 서버 `unlinked` · PLAN 154-1)
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
@@ -35,6 +36,8 @@ import {
type LinkedSnapshot,
type PlacedItem,
type StationCombo,
type UnlinkedFacility,
unlinkedAt,
widthOf,
} from "./B08_Quantity_Summary_Data";
import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc";
@@ -75,6 +78,8 @@ interface PipeSummary {
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
/** 미연결 시설(154-1) */
unlinked: UnlinkedFacility[];
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
@@ -130,6 +135,7 @@ function buildTable(
pipe: PipeSummary | null,
comboPath: (id: string) => string | null,
interval: number,
unlinked: readonly UnlinkedFacility[],
onRowClick: (station: number, label: string) => void,
): HTMLElement {
const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values]));
@@ -137,10 +143,16 @@ function buildTable(
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
: null;
const labels = stations.map((station) => stationLabel(station, interval));
// 미연결 시설 측점 = 「· 미연결」(값 빈칸 · 누르면 바로 연결 · 154)
const shown = stations.map((station, i) =>
unlinkedAt(unlinked, station).length
? `${labels[i]} · ${L("B08_Quantity_Summary_Unlinked")}`
: labels[i],
);
const stationHead = L("B08_Quantity_Summary_Station");
const table = createDataTable({
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) },
{ key: "station", label: stationHead, width: widthOf(stationHead, ...shown) },
...columns.map((column) => ({
key: column.id,
label: column.name,
@@ -153,7 +165,7 @@ function buildTable(
],
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
rows: stations.map((station, i) => ({
station: labels[i],
station: shown[i],
...values.get(stationKey(station)),
})),
total,
@@ -214,7 +226,8 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
);
const mine = linkedOnly(structures.structures, linked);
const things: PlacedItem[] = [...mine, ...placed];
const stations = summaryStations(things);
const unlinked = saved?.unlinked ?? [];
const stations = summaryStations([...things, ...unlinked]);
// 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부)
const served = new Set(saved?.types ?? []);
const others = [
@@ -232,7 +245,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
parts.push(note(L("B08_Quantity_Summary_NoCombo")));
const draw = (): HTMLElement =>
stations.length
? buildTable(stations, columns, live(), comboPath, interval, (station, label) =>
? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) =>
openSheets(projectId, label, combosAt(things, station), linked, describe),
)
: note(L("B08_Quantity_Summary_NoStation"));
@@ -154,15 +154,40 @@ def test_사본_없는_시설_알림_응답에_사본(root):
out = _run(root)
# 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳
assert not any("wing" in c["id"] for c in out["columns"])
assert "조합 사본 없는 시설 7곳 — B05 · B06 좌측 패널에서 조합 연결 · [저장]" in out["notes"]
assert "조합 사본 없는 시설 7곳 — 「미연결」 줄을 눌러 조합 연결" in out["notes"]
assert not any("관 연장 없음" in n for n in out["notes"])
assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"}
def test_미연결_시설_칸_전부_연결_여부(root):
"""154-1 — 셈한 칸 기준 미연결 시설 일곱 · 시설마다 칸 전부(본체 · 그룹) + 연결 여부."""
out = _run(root)
got = {(round(u["chainage_m"]), u["type_id"]): u for u in out["unlinked"]}
assert sorted(got) == [
(100, "pipe"), (200, "pipe"), (300, "pipe"), (400, "pipe"),
(500, "ford_bridge"), (600, "ford_bridge"), (700, "open_ditch"),
] # fmt: skip
one = got[(100, "pipe")]
assert one["id"] == "pipe@100.0" and one["facility"] == "pipe" and one["name"] == "배관"
body = one["slots"][0]
assert body["copy"] == "pipe@100.0" and body["group"] is None and body["linked"] is True
outlet = next(s for s in one["slots"] if s["copy"] == "pipe@100.0__g9lq9uk")
assert outlet["linked"] is False and outlet["item"]["key"] == "SS000002" and outlet["group"]
assert not got[(200, "pipe")]["slots"][0]["linked"] # 다른 항목 사본
assert got[(400, "pipe")]["slots"][0]["item"] is None
ford = got[(500, "ford_bridge")]
assert ford["facility"] == "ford_bridge"
assert any(s["copy"] == "pipe@500.0__gford" and s["linked"] for s in ford["slots"])
ditch = got[(700, "open_ditch")]
assert ditch["id"] == "s-ditch" and ditch["facility"] is None
assert ditch["slots"][0] == {"copy": "s-ditch", "group": None, "item": None, "linked": False}
def test_화면_조합_이름_구조물도는_사본에서():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "loadPickerRows" not in ui # 층 목록 GET 안 씀
assert "linkedOnly(" in ui
assert "unlinkedAt(" in ui and "B08_Quantity_Summary_Unlinked" in ui # 154-1 미연결 줄
drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
assert "readTemplate" not in drawing # 층 폴백 없음
assert "B08_Quantity_Sheets_NoCopy" in drawing
+1
View File
@@ -58,4 +58,5 @@ export const ui_locales_b4 = {
"No placed structures or pipes.",
],
B08_Quantity_Summary_Total: ["계", "Total"],
B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"],
} as const;