From 3c510d27f4960838b000097d7cb32410f72ee9bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 19:04:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EB=AF=B8=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=84=A4=EB=8F=84=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=A4=84=20=E2=80=94=20=EA=B0=92?= =?UTF-8?q?=20=EB=B9=88=EC=B9=B8=20=C2=B7=20=E3=80=8C=EB=AF=B8=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=E3=80=8D=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20unlinked(154-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 pipe_summary 응답 unlinked = 셈한 칸에 사본 없는 시설 · 칸 전부 + 연결 여부(몸 = Summary_Sheet) - 같은 측점 박스 + 관 = 종류로 가름 · 세월교 필수 잇기(gford · gfordin · gfordout) 칸 포함 - 화면 = 그 측점 줄 「· 미연결」 · 알림 글 「「미연결」 줄을 눌러 조합 연결」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Router_Summary.py | 26 +++++--- B08_Quantity/B08_Quantity_Summary_Data.ts | 27 ++++++++ B08_Quantity/B08_Quantity_Summary_Sheet.py | 61 ++++++++++++++++++- B08_Quantity/B08_Quantity_UI_Summary.ts | 23 +++++-- .../tester/test_143_1_b08_linked_snapshots.py | 27 +++++++- ui_template/ui_template_locale_b4.ts | 1 + 6 files changed, 148 insertions(+), 17 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 9b654eee8..ec23fcb53 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -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()}, + ), } diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 028295f9a..8be14f298 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -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( 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; diff --git a/B08_Quantity/B08_Quantity_Summary_Sheet.py b/B08_Quantity/B08_Quantity_Summary_Sheet.py index c1e4f4924..0ad778833 100644 --- a/B08_Quantity/B08_Quantity_Summary_Sheet.py +++ b/B08_Quantity/B08_Quantity_Summary_Sheet.py @@ -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"])) diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index ddb310f21..1f3424ec7 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -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; + /** 미연결 시설(154-1) */ + unlinked: UnlinkedFacility[]; } async function fetchPipeSummary(projectId: string): Promise { @@ -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")); diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 6fb1cda99..2f48bbcbf 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -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 diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 962572564..b1f33ebab 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -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;