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:
@@ -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()},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user