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_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index 4d0dfee05..43172e579 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -46,3 +46,26 @@ grid-template-columns: minmax(0, 1fr); } } + +/* 「조합 연결」 칸(154-2) — 시설 이름 · 칸 줄(이름 · 연결 여부 · [선택]) */ +.b08-link__facility { + margin: 0; + font-weight: var(--font-weight-semibold); + color: var(--color-text); +} + +.b08-link__slot { + display: grid; + grid-template-columns: minmax(120px, 200px) 1fr auto; + align-items: center; + gap: var(--spacing-8); + padding-left: var(--spacing-16); +} + +.b08-link__state { + color: var(--color-text-secondary); +} + +.b08-link__state.is-missing { + color: var(--color-danger); +} diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index 60dc1a94e..2e3167817 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -6,6 +6,7 @@ * * 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만 * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) + * 미연결 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2) — 저장하면 닫고 다시 셈 * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ @@ -16,7 +17,13 @@ import { PART_OF, projectItems } from "@ui/ui_template_item_picker_project"; import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; -import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data"; +import type { + ComboLabel, + LinkedSnapshot, + StationCombo, + UnlinkedFacility, +} from "./B08_Quantity_Summary_Data"; +import { linkSection } from "./B08_Quantity_UI_Link"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -37,6 +44,10 @@ export interface SheetsRequest { /** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */ linked: Record; describe: (item: SummaryItem) => ComboLabel | null; + /** 그 측점 미연결 시설(154) */ + unlinked: UnlinkedFacility[]; + /** 바로 연결을 저장한 뒤 — 집계표 다시 셈 */ + onLinked: () => void; } /** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */ @@ -108,15 +119,30 @@ export function openStructureSheets(request: SheetsRequest): void { handles.forEach((cards) => cards.destroy()); return true; }, - mount: (body) => { - if (!request.combos.length) return void body.append(note(L("B08_Quantity_Summary_NoCombo"))); - body.append(note(L("B08_Quantity_Drawing_Loading"))); + mount: (body, close) => { + const linking = request.unlinked.length + ? [ + linkSection(request.projectId, request.unlinked, () => { + closed = true; + handles.forEach((cards) => cards.destroy()); + close(); + request.onLinked(); + }), + ] + : []; + if (!request.combos.length) + return void body.append( + ...linking, + ...(linking.length ? [] : [note(L("B08_Quantity_Summary_NoCombo"))]), + ); + body.append(...linking, note(L("B08_Quantity_Drawing_Loading"))); void (async () => { try { const { createItemCards } = await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"); if (closed) return; body.replaceChildren( + ...linking, ...request.combos.map((combo) => comboSection(request, combo, createItemCards, handles), ), @@ -124,7 +150,7 @@ export function openStructureSheets(request: SheetsRequest): void { } catch (error) { if (closed) return; const reason = error instanceof Error ? error.message : String(error); - body.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`)); + body.replaceChildren(...linking, note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`)); } })(); }, diff --git a/B08_Quantity/B08_Quantity_UI_Link.ts b/B08_Quantity/B08_Quantity_UI_Link.ts new file mode 100644 index 000000000..b7f9df6cb --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_Link.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * B08_Quantity_UI_Link.ts + * 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2) — 그 측점 미연결 시설의 칸 전부 + 연결 여부. + * + * [선택] → 공용 고르기 모달(`openItemPicker` · 누를 때 `import()`) → 바로 저장 + * = PUT …/items/{사본 id}/link(그 칸 하나만 정본에 · 서버가 사본 맞춤) → 집계표 다시 셈 + * B05/B06 세션 초안(구조물 · 관)이 있으면 막고 알림 — 그쪽 [저장]이 목록 통째로 덮어 연결이 사라짐 + * ========================================================================== */ + +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, showToast } from "@ui/ui_template_elements"; +import { projectItems } from "@ui/ui_template_item_picker_project"; +import { readPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { UnlinkedFacility, UnlinkedSlot } from "./B08_Quantity_Summary_Data"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** B05/B06 에 저장 안 한 구조물 · 관 조작이 있나(세션 초안). */ +export const hasPendingDesign = (projectId: string): boolean => + readPendingStructures(projectId) !== null || readPendingPipes(projectId) !== null; + +/** 칸 하나 고르고 바로 저장 — 저장했으면 true(취소 · 막힘 · 실패 = false). */ +async function pickAndLink( + projectId: string, + facility: UnlinkedFacility, + slot: UnlinkedSlot, +): Promise { + if (hasPendingDesign(projectId)) { + showToast(L("B08_Quantity_Link_Pending"), "warning", 5000); + return false; + } + const { openItemPicker } = await import("@ui/ui_template_item_picker"); + const result = await openItemPicker({ + current: slot.item, + canCopy: true, + projectId, + structureId: slot.copy, + }); + if (result.action === "cancel") return false; + const item: SummaryItem | null = result.action === "apply" ? result.item : null; + try { + const failed = await projectItems(projectId).link(slot.copy, facility.facility, item); + if (failed.length) + showToast(`${L("B08_Quantity_Link_CopyFailed")} — ${failed.join(", ")}`, "warning"); + else showToast(L("B08_Quantity_Link_Saved"), "success"); + return true; + } catch (error) { + showToast(`${L("B08_Quantity_Link_Failed")} — ${String(error)}`, "error"); + return false; + } +} + +/** 「조합 연결」 칸 — 시설마다 이름 + 칸 줄(이름 · 연결 여부 · [선택]). `onLinked` = 저장 뒤(모달 닫고 다시 셈). */ +export function linkSection( + projectId: string, + facilities: readonly UnlinkedFacility[], + onLinked: () => void, +): HTMLElement { + const section = document.createElement("section"); + section.className = "b08-sheets__combo b08-link"; + section.dataset.b08Link = "section"; + const title = document.createElement("h4"); + title.className = "b08-sheets__title"; + title.textContent = L("B08_Quantity_Link_Title"); + section.append(title); + for (const facility of facilities) { + const head = document.createElement("p"); + head.className = "b08-link__facility"; + head.textContent = facility.name; + section.append(head); + for (const slot of facility.slots) { + const row = document.createElement("div"); + row.className = "b08-link__slot"; + row.dataset.b08LinkSlot = slot.copy; + const name = document.createElement("span"); + name.textContent = slot.group ?? L("B08_Quantity_Link_Body"); + const state = document.createElement("span"); + state.className = slot.linked ? "b08-link__state" : "b08-link__state is-missing"; + state.textContent = slot.linked + ? `${L("B08_Quantity_Link_Linked")} · ${slot.item?.name ?? ""}` + : L("B08_Quantity_Summary_Unlinked"); + const button = createButton({ + label: L("B08_Quantity_Link_Pick"), + variant: "ghost", + size: "sm", + onClick: () => { + button.disabled = true; + void pickAndLink(projectId, facility, slot).then((saved) => + saved ? onLinked() : (button.disabled = false), + ); + }, + }); + row.append(name, state, button); + section.append(row); + } + } + return section; +} diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index ddb310f21..b32efc609 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -5,12 +5,14 @@ * * 구조물 = 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`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 * 열 폭 = 맨 아래 단 글(+ 칸 값)만 잼 · 위 단 머리는 줄바꿈(`B08_Quantity_UI_Page.css` · 120-2) * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) + * 미연결 시설 = 모달 맨 위 「조합 연결」 → 바로 저장 → 탭 몸통을 새로 그림(154-2) * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) * ========================================================================== */ @@ -34,10 +36,12 @@ import { type ComboLabel, type LinkedSnapshot, type PlacedItem, - type StationCombo, + type UnlinkedFacility, + unlinkedAt, widthOf, } from "./B08_Quantity_Summary_Data"; import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; +import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { @@ -75,6 +79,8 @@ interface PipeSummary { pipes: PipeLength[]; /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ linked: Record; + /** 미연결 시설(154-1) */ + unlinked: UnlinkedFacility[]; } async function fetchPipeSummary(projectId: string): Promise { @@ -130,6 +136,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 +144,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 +166,7 @@ function buildTable( ], // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 rows: stations.map((station, i) => ({ - station: labels[i], + station: shown[i], ...values.get(stationKey(station)), })), total, @@ -167,16 +180,10 @@ function buildTable( } /** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */ -async function openSheets( - projectId: string, - station: string, - combos: StationCombo[], - linked: Record, - describe: (item: SummaryItem) => ComboLabel | null, -): Promise { +async function openSheets(request: SheetsRequest): Promise { try { const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); - openStructureSheets({ projectId, station, combos, linked, describe }); + openStructureSheets(request); } catch (error) { showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); } @@ -214,7 +221,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,8 +240,21 @@ 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) => - openSheets(projectId, label, combosAt(things, station), linked, describe), + ? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) => + openSheets({ + projectId, + station: label, + combos: combosAt(things, station), + linked, + describe, + unlinked: unlinkedAt(unlinked, station), + // 새 몸통에 다시 그림 — 옛 몸통은 떨어져 옵션 듣기가 스스로 빠짐(겹침 없음) + onLinked: () => { + const fresh = document.createElement("div"); + host.replaceWith(fresh); + void renderStructureSummary(fresh, projectId); + }, + }), ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index a6fef7c8f..5f211bd52 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -1,5 +1,5 @@ -"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산 -(PLAN 25-2b · 26-2b). +"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산 · +바로 연결(PLAN 25-2b · 26-2b · 154-2). 복사본 = `{프로젝트}/templates/structures/{구조물 id}/` (`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id. @@ -15,7 +15,10 @@ from collections.abc import Callable from pathlib import Path from typing import Any +from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures +from common_util.common_util_drainage_pipes import pipe_points_path_in from common_util.common_util_json import atomic_write_json +from common_util.common_util_summary_item import GROUP_JOIN from M02_MasterTemplete import M02_Item_Personal as personal from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Template_Layers as layers @@ -342,3 +345,62 @@ def calc(project_root: str | Path, folder: str) -> dict[str, Any]: read_json(target / PICKS_FILE) or {}, read_json(target / KINDS["면적"]), ) + + +def _put(record: dict[str, Any], gid: str, item: dict[str, Any] | None) -> None: + """조합 칸 하나 — 본체 = `summary_item` · 그룹 = `group_items[gid]` · None = 연결 풀기.""" + if not gid: + record["summary_item"] = item + return + groups = dict(record.get("group_items") or {}) + if item is None: + groups.pop(gid, None) + else: + groups[gid] = item + record["group_items"] = groups or None + + +def link( + project_root: str | Path, copy: str, facility: str | None, item: dict[str, Any] | None +) -> None: + """B08 바로 연결(154-2) — 그 시설 조합 칸 하나만 정본에 씀 · 다른 칸 · 시설 그대로. + + `copy` = 사본 id(본체 = 시설 id · 그룹 = `{id}__{그룹}`) · 관 지점(`pipe@측점`)은 같은 측점 + 박스 + 관을 `facility` 로 가름 → `pipe_points.json`(노선 지문 그대로) · 구조물 → + `structures.json`(판번호 +1). 사본 맞춤(`safe_sync`)은 부른 쪽.""" + owner, _, gid = copy.partition(GROUP_JOIN) + root = Path(project_root) + if owner.startswith("pipe@"): + path = pipe_points_path_in(root) + document = read_json(path) + points = document.get("points") if isinstance(document, dict) else None + at = owner.removeprefix("pipe@") + kind = facility or "pipe" + found = next( + ( + point + for point in points or [] + if isinstance(point, dict) + and f"{round(float(point.get('chainage_m', -1)), 3)}" == at + and (point.get("facility") or "pipe") == kind + ), + None, + ) + if found is None: + raise LookupError(f"관 지점이 없음 「{copy}」 ({kind})") + _put(found, gid, item) + for key in ("summary_item", "group_items"): # 저장 꼴(`PipePoint.as_dict`) — 빈 칸은 뺌 + if not found.get(key): + found.pop(key, None) + atomic_write_json(path, document) + return + if not owner or owner.startswith("#"): + raise ValueError(f"id 없는 옛 구조물은 못 이음 「{copy}」 — B05 에서 한 번 [저장]") + revision, structures = load_structures(str(root)) + target = next((one for one in structures if one.structure_id == owner), None) + if target is None: + raise LookupError(f"구조물이 없음 「{owner}」") + record = {"summary_item": target.summary_item, "group_items": target.group_items} + _put(record, gid, item) + target.summary_item, target.group_items = record["summary_item"], record["group_items"] + save_structures(str(root), structures, base_revision=revision) diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 464ecaab4..0a6188e6b 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -8,6 +8,8 @@ PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적은 서버 계산값 POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) + PUT …/items/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설 + 조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed} POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 · 기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값) 종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) · @@ -29,7 +31,7 @@ from common_util.common_util_summary_item import parse_summary_item from M02_MasterTemplete import M02_Item_Copy as copies from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete.M02_Item_Snapshot import importer +from M02_MasterTemplete.M02_Item_Snapshot import folder_name, importer, safe_sync from M02_MasterTemplete.M02_Item_Source import item_name router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) @@ -59,6 +61,11 @@ class SaveAsBody(BaseModel): 이름: str | None = None +class LinkBody(BaseModel): + summary_item: dict[str, Any] | None = None + facility: str | None = None # 관 지점 종류(같은 측점 박스 + 관 가름) · 구조물은 없음 + + class NewBody(BaseModel): structure: str 경로: list[str] = [] @@ -139,6 +146,28 @@ async def read_item_file( return await _run(copies.read_file, await _root(session, project_id), folder, kind) +@router.put("/{project_id}/items/{copy}/link") # `{폴더}/{종류}` 길보다 먼저 +async def link_item( + project_id: str, copy: str, body: LinkBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + item = None + if body.summary_item is not None: + raw = body.summary_item + try: # 모양 먼저(이름은 자리만) — 이름이 없으면 층 SS 줄에서 + item = parse_summary_item({**raw, "name": raw.get("name") or "-"}) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if not raw.get("name"): + try: + item["name"] = item_name(item) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + root = await _root(session, project_id) + await _run(copies.link, root, copy, body.facility, item) + failed = await asyncio.to_thread(safe_sync, root, importer(session)) + return {"폴더": folder_name(copy), "snapshot_failed": failed} + + def _recalc(document: dict) -> dict: try: return spreadsheet.with_server_values(document) 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/resources/tester/test_154_2_b08_direct_link.py b/resources/tester/test_154_2_b08_direct_link.py new file mode 100644 index 000000000..12bd79719 --- /dev/null +++ b/resources/tester/test_154_2_b08_direct_link.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""B08 미연결 구조물 바로 연결(PLAN 154-2) — 임시 저장소. + +- 서버 `link` = 그 시설 조합 칸 하나만 정본에 씀(구조물 판번호 +1 · 관 지점 노선 지문 그대로) +- 같은 측점 박스 + 관 = `facility` 로 가름 · 그룹 칸 = `group_items[gid]` · None = 풀기 +- 저장 뒤 사본 맞춤 → 사본 폴더 생김 → 집계 응답 `unlinked` 에서 빠짐 +- 길 차례(`{폴더}/{종류}` 보다 먼저) · 화면 = 초안 있으면 막음 · 고르기 모달 · 다시 그림 +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from config import config_system +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition +from resources.tester.helper_m02_master_sample import SAMPLE + +ROOT = Path(__file__).resolve().parents[2] +PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} +RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"} +MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} + +POINTS = [ + {"chainage_m": 100.0, "source": "user", "options": {"pipe_diameter_mm": 800}, + "group_items": {"grv6lny": PIPE}}, + {"chainage_m": 100.0, "source": "user", "facility": "box_culvert", "options": {}}, + {"chainage_m": 200.0, "source": "user", "options": {"pipe_diameter_mm": 800}}, +] # fmt: skip +STRUCTURES = [ + {"structure_id": "st0", "type_id": "open_ditch", "placement": "point", "chainage_m": 300}, + {"structure_id": "st1", "type_id": "open_ditch", "placement": "point", "chainage_m": 400, + "summary_item": MS}, +] # fmt: skip + + +@pytest.fixture +def root(tmp_path: Path, monkeypatch) -> Path: + system = tmp_path / "master" + system.mkdir() + shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE) + for kind in ("table", "structure", "area"): + shutil.copytree(SAMPLE / kind, system / kind) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + found = storage / "3" / "12" / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + (edits / "pipe_points.json").write_text( + json.dumps({"route_signature": "sig-1", "points": POINTS}, ensure_ascii=False), + encoding="utf-8", + ) + route = found / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text( + json.dumps({"revision": 4, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + snap.sync_snapshots(found) + return found + + +def _points(root: Path) -> dict[str, Any]: + path = root / "B04_PreProcess/drainage/edits/pipe_points.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def _structures(root: Path) -> dict[str, Any]: + path = root / "B05_Profile/route/structures.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def test_관_지점_그_칸만_박스_관_가름(root): + before = _points(root) + copies.link(root, "pipe@100.0", "box_culvert", RV) + after = _points(root) + assert after["route_signature"] == "sig-1" + assert after["points"][1]["summary_item"] == RV # 박스만 + assert after["points"][0] == before["points"][0] # 같은 측점 관 그대로 + assert after["points"][2] == before["points"][2] + copies.link(root, "pipe@100.0__g9lq9uk", "pipe", RV) + pipe = _points(root)["points"][0] + assert pipe["group_items"] == {"grv6lny": PIPE, "g9lq9uk": RV} # 다른 그룹 그대로 + assert "summary_item" not in pipe + copies.link(root, "pipe@100.0__g9lq9uk", "pipe", None) # 풀기 + assert _points(root)["points"][0]["group_items"] == {"grv6lny": PIPE} + copies.link(root, "pipe@100.0", "box_culvert", None) + assert "summary_item" not in _points(root)["points"][1] + + +def test_구조물_그_칸만_판번호(root): + copies.link(root, "st0", None, RV) + got = _structures(root) + assert got["revision"] == 5 + st0, st1 = got["structures"] + assert st0["summary_item"] == RV and st0["chainage_m"] == 300 + assert st1["summary_item"] == MS # 다른 구조물 그대로 + copies.link(root, "st1__gA", None, RV) + got = _structures(root) + assert got["revision"] == 6 + assert got["structures"][1]["group_items"] == {"gA": RV} + assert got["structures"][1]["summary_item"] == MS + + +def test_못_잇는_것(root): + with pytest.raises(LookupError): + copies.link(root, "pipe@999.0", "pipe", RV) + with pytest.raises(LookupError): + copies.link(root, "pipe@100.0", "ford_bridge", RV) + with pytest.raises(LookupError): + copies.link(root, "nope", None, RV) + with pytest.raises(ValueError): + copies.link(root, "#0", None, RV) + + +def test_저장_뒤_사본_생기고_미연결에서_빠짐(root): + names = definition.read()["종류"]["pipe"]["이름"] + designs = [{"chainage_m": 200.0, "design": {"pipe_length_m": 12}}] + first = summary.pipe_summary(root, designs, names, 6.0) + assert any(u["id"] == "st0" for u in first["unlinked"]) + assert any(u["id"] == "pipe@200.0" for u in first["unlinked"]) + copies.link(root, "st0", None, RV) + copies.link(root, "pipe@200.0", "pipe", PIPE) + assert snap.safe_sync(root) == [] + assert (snap.structures_dir(root) / snap.folder_name("st0") / snap.MANIFEST_NAME).is_file() + linked = snap.linked_snapshots(root) + assert {"st0", "pipe@200.0"} <= set(linked) + after = summary.pipe_summary(root, designs, names, 6.0) + assert not any(u["id"] == "st0" for u in after["unlinked"]) + pipe200 = next((u for u in after["unlinked"] if u["id"] == "pipe@200.0"), None) + assert pipe200 is None or pipe200["slots"][0]["linked"] # 본체는 연결됨 + + +def test_길_차례_링크가_먼저(): + from M02_MasterTemplete.M02_Project_Items_Router import router + + paths = [(route.path, sorted(route.methods)) for route in router.routes] + link = paths.index(("/api/projects/{project_id}/items/{copy}/link", ["PUT"])) + write = paths.index(("/api/projects/{project_id}/items/{folder}/{kind}", ["PUT"])) + assert link < write + + +def test_화면_초안이면_막고_고르기_모달_다시_그림(): + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8") + assert "readPendingStructures(projectId) !== null" in ui + assert "readPendingPipes(projectId) !== null" in ui + assert 'import("@ui/ui_template_item_picker")' in ui + assert ".link(slot.copy, facility.facility, item)" in ui + api = (ROOT / "ui_template/ui_template_item_picker_project.ts").read_text(encoding="utf-8") + assert "/link`" in api + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + assert "host.replaceWith(fresh)" in page and "unlinkedAt(unlinked, station)" in page diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index d20a9786f..bcf7b5cf0 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -9,6 +9,7 @@ * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} + * PUT …/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설 조합 칸 하나만 정본에 → {폴더, snapshot_failed} * POST …/{폴더}/calc 복사본만으로 계산 — 층 셈 꼴(줄 · 자재 · 기준합 · 반영합) + 알약 · 빠진입력(142-1) * 일위대가(39-1c · 142-1) — 모달 일위대가 카드(M02 카드 · 읽기 전용)가 `calcLabor` 로 셈 · 넣을 값 목록은 `fetchLogicInputs` * ========================================================================== */ @@ -19,7 +20,7 @@ import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor import type { LaborDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import type { LaborCalc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Calc"; import type { ValueDoc } from "../M02_MasterTemplete/M02_Labor_Values_Model"; -import { layerOf, ownerOf, type PickerRow } from "./ui_template_item_picker_data"; +import { layerOf, ownerOf, type PickerRow, type SummaryItem } from "./ui_template_item_picker_data"; /** 복사본 폴더 안 부분 — 25-1b 약속 그대로 */ export type ProjectPart = "도면" | "면적" | "입력값" | "고른자재" | "일위대가"; @@ -134,6 +135,9 @@ export interface ProjectItems { calc: (folder: string) => Promise; /** 복사본 셈 답 통째 — 모달 일위대가 카드(Labor_Detail)가 씀 · 못 하면 서버 까닭 그대로 던짐 */ calcLabor: (folder: string) => Promise; + /** 바로 연결(154-2) — 시설 조합 칸 하나만 정본에(null = 풀기) · `facility` = 관 지점 종류 · + * 돌려받음 = 못 만든 사본 폴더 · 못 하면 서버 까닭 그대로 던짐 */ + link: (copy: string, facility: string | null, item: SummaryItem | null) => Promise; } const clients = new Map(); @@ -237,6 +241,15 @@ export function projectItems(projectId: string): ProjectItems { }; }, calcLabor: (folder) => calcCopy(folder), + link: async (copy, facility, item) => { + const reply = await call(`${base}/${enc(copy)}/link`, { + method: "PUT", + body: JSON.stringify({ summary_item: item, facility }), + }); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + forget(copy); + return (reply.body as { snapshot_failed?: string[] } | null)?.snapshot_failed ?? []; + }, }; clients.set(projectId, client); return client; diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 962572564..f7e155491 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -58,4 +58,19 @@ export const ui_locales_b4 = { "No placed structures or pipes.", ], B08_Quantity_Summary_Total: ["계", "Total"], + B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"], + B08_Quantity_Link_Title: ["조합 연결", "Link combinations"], + B08_Quantity_Link_Body: ["본체", "Body"], + B08_Quantity_Link_Linked: ["연결됨", "Linked"], + B08_Quantity_Link_Pick: ["선택", "Select"], + B08_Quantity_Link_Saved: ["조합을 연결했습니다.", "Combination linked."], + B08_Quantity_Link_Failed: ["조합을 연결하지 못했습니다", "Failed to link the combination"], + B08_Quantity_Link_CopyFailed: [ + "연결은 저장했으나 사본을 못 만듦", + "Linked, but the project copy could not be made", + ], + B08_Quantity_Link_Pending: [ + "B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.", + "Unsaved edits in B05 · B06 — save there first, then link.", + ], } as const;