From b40a129cb55a1938e5a902b467641c55ce3bdc88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 14:51:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EC=97=B0=EA=B2=B0=EB=90=9C=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20=EC=9B=90=EC=B2=9C=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=82=AC=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EC=B9=B8=EB=A7=8C=20=C2=B7=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EB=8F=84=20=EC=82=AC=EB=B3=B8=EC=97=90=EC=84=9C(143-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Item_Snapshot.linked_snapshots — 지정 ∩ 같은 항목 사본 {구조물 id: 항목 · 폴더 · 부분 · 경로} · B08 · B09 공용 - 집계표 서버 — 잇기 목록을 사본으로 거름 · 조합 모두 없는 칸은 안 셈 · 세월교 날 잇기 · 관 몫 본체 잇기 · 「조합 사본 없는 시설 n곳」 알림 · 응답 linked - 화면 — 조합 이름 = 사본 경로(층 목록 GET 없음) · 측점 · 조합 열 = 사본 있는 것만 · 구조물도 모달 = 사본만(없으면 사본 없음) - 시험 — test_98 · 120 · 131 픽스처 잇기 · test_61_2 사본만 · 새 test_143_1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Router_Summary.py | 61 ++++-- B08_Quantity/B08_Quantity_Summary_Data.ts | 45 +++- B08_Quantity/B08_Quantity_UI_Drawing.ts | 88 +++----- B08_Quantity/B08_Quantity_UI_Summary.ts | 52 ++--- M02_MasterTemplete/M02_Item_Snapshot.py | 27 +++ .../tester/test_120_b08_drain_summary.py | 32 ++- resources/tester/test_131_b08_material_fix.py | 4 +- .../tester/test_143_1_b08_linked_snapshots.py | 199 ++++++++++++++++++ .../tester/test_61_2_b08_structure_sheets.py | 12 +- resources/tester/test_98_b08_pipe_summary.py | 18 +- ui_template/ui_template_locale_b4.ts | 11 +- 11 files changed, 421 insertions(+), 128 deletions(-) create mode 100644 resources/tester/test_143_1_b08_linked_snapshots.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 95e78bd6a..fe16b794a 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -46,6 +46,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import _project_root 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 +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs @@ -159,6 +160,8 @@ class _Sheet: def __init__(self) -> None: self.columns: dict[str, dict[str, Any]] = {} self.rows: dict[int, dict[str, Any]] = {} + self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1) + self.unlinked: set[str] = set() def add( self, @@ -170,6 +173,9 @@ class _Sheet: value: float | None, combos: list[str | None], ) -> None: + if not any(combos): # 그 칸을 낸 조합 사본이 없음 — 안 셈(143-1) + self.unlinked.add(self.owner) + return cid = f"pipe:{key}" column = self.columns.setdefault( cid, {"id": cid, "group": group, "label": label, "combos": [], "_sort": sort} @@ -367,15 +373,17 @@ def _ford_pipes( one_m: float | None, names: dict[str, str], pipes: list[dict[str, Any]], + body: str | None, ) -> None: """세월교 관 몫 — 한 련 길이 × 련 수를 관매설 · 커플링 열에 더함(관 공과 같은 열 · 131). - `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈). + `pipes` 에는 련마다 한 줄(좌측 패널 1본 길이로 커플링을 다시 셀 때 련 수만큼 셈) · + 조합 = 시설 본체 + 본체 잇기(`body` · 143-1). """ diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) count = int(_num(base["options"].get("pipe_count")) or 1) head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] - combos = [_combo(base.get("summary_item"))] + combos = [_combo(base.get("summary_item")), body] each = couplings(length, one_m) sheet.add( at, @@ -395,7 +403,8 @@ def _ford_pipes( None if each is None else each * count, combos, ) - pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count + if any(combos): + pipes += [{"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}] * count def _pavement( @@ -467,12 +476,15 @@ def pipe_summary( names: dict[str, str], one_m: float | None, kinds: dict[str, Any] | None = None, + linked: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: """횡단배수 한 곳마다 컨테이너 값 → 집계표 열 · 줄 · 계(저장 안 함). - `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음). + `names` = 관 정의 이름(공용 흙막이 · 돌붙임 머리) · `kinds` = M02 정의(비면 읽음) · + `linked` = 연결된 사본(비면 읽음) — 칸마다 그 칸을 낸 컨테이너 사본이 있을 때만 셈(143-1). """ kinds = kinds or definition.read() + linked = snapshots.linked_snapshots(project_root) if linked is None else linked items = fill.collect_items( project_root, fill.pipe_lengths_from_designs(designs), @@ -488,15 +500,19 @@ 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] = {} # 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림) for item in items: - if item["type_id"] not in DRAIN_TYPES: - continue owner, _, link = item["복사본"].partition(GROUP_JOIN) + if item["type_id"] not in DRAIN_TYPES: + others[owner] = others.get(owner, False) or item["복사본"] in linked + continue entry = by_owner.setdefault((item["type_id"], owner), {"links": {}}) + # 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1) + combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None if link: - entry["links"][link] = _combo(item.get("summary_item")) + entry["links"][link] = combo else: - entry["base"] = item + entry["base"] = {**item, "summary_item": item.get("summary_item") if combo else None} sheet = _Sheet() pipes: list[dict[str, Any]] = [] @@ -506,24 +522,32 @@ def pipe_summary( if base is None: continue links = entry["links"] + sheet.owner = owner point = raw.get((type_id, owner)) options = dict(point.options or {}) if point else {} at = float(base["chainage_m"]) if type_id != "pipe": box_length = fill._nearest(box_lengths, at) if type_id == "box_culvert" else None - no_box += type_id == "box_culvert" and not box_length - group = (point.group_items if point else None) or {} + group = { # 세월교 날 잇기(저장된 잇기)도 사본 있는 것만 + gid: chosen + for gid, chosen in ((point.group_items if point else None) or {}).items() + if f"{owner}{GROUP_JOIN}{gid}" in linked + } _drain(sheet, at, type_id, base, options, links, group, kinds, names, box_length) + body_link = BOX_LINKS["body"] if type_id == "box_culvert" else FORD_LINKS["body"] + body = links.get(body_link) or _combo(group.get(body_link)) + counted = bool(base["summary_item"] or body) # 본체 칸을 셈(사본 있음) + no_box += type_id == "box_culvert" and counted and not box_length if type_id == "ford_bridge": ford_length = fill._nearest(ford_lengths, at) - no_ford += not ford_length - _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes) + no_ford += counted and not ford_length + _ford_pipes(sheet, at, base, ford_length, one_m, names, pipes, body) continue diameter = int(_num(base["options"].get("pipe_diameter_mm")) or 1000) pipe_head = ["관 공", f"Φ{names.get(f'pipe.diameter={diameter}', diameter)}m/m"] pipe_combos = [_combo(base.get("summary_item")), links.get(LINKS["pipe"])] length = base.get("관연장") - no_length += not length + no_length += any(pipe_combos) and not length sheet.add( at, f"len|{diameter}", @@ -542,9 +566,10 @@ def pipe_summary( couplings(length, one_m), pipe_combos, ) - pipes.append({"chainage_m": at, "column": f"pipe:cp|{diameter}", "length_m": length}) + if any(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 + no_basin += bool(links.get(LINKS["inlet"])) and shape is None if shape: sheet.add( at, @@ -563,6 +588,11 @@ 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 좌측 패널에서 조합 연결 · [저장]" + ) if no_length: notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐") if no_basin: @@ -579,6 +609,7 @@ def pipe_summary( "one_length_m": one_m, "types": list(DRAIN_TYPES), "pipes": pipes, + "linked": linked, } diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index e228d23be..028295f9a 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -4,12 +4,14 @@ * * 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순) * 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열 + * 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1) * * 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다. * 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림. * ========================================================================== */ import { + GROUP_JOIN, readGroupItems, readSummaryItem, type SummaryItem, @@ -25,6 +27,35 @@ export interface PlacedItem { group_items?: unknown; } +/** 연결된 사본 한 건(서버 `M02_Item_Snapshot.linked_snapshots` · 143-1) — `경로` = 가져온 때 M02 경로. */ +export interface LinkedSnapshot { + 항목: SummaryItem; + 폴더: string; + /** 있는 부분(도면 · 면적 · 일위대가) */ + 부분: string[]; + 경로: string[]; +} + +/** 사본 있는 조합만 남김 — 본체 = `{구조물 id}` · 그룹 = `{구조물 id}__{그룹}` 사본 · 하나도 없으면 뺌. */ +export function linkedOnly( + things: readonly T[], + linked: Readonly>, +): T[] { + const out: T[] = []; + for (const thing of things) { + const id = thing.structure_id ?? ""; + const own = id in linked ? readSummaryItem(thing.summary_item) : null; + const groups = Object.fromEntries( + Object.entries(readGroupItems(thing.group_items)).filter( + ([gid]) => `${id}${GROUP_JOIN}${gid}` in linked, + ), + ); + if (own || Object.keys(groups).length) + out.push({ ...thing, summary_item: own, group_items: groups }); + } + return out; +} + /** M02 조합 정보에서 읽은 구분 · 이름 — 못 찾으면 null(저장된 이름으로 보임). */ export interface ComboLabel { group: string; @@ -84,11 +115,12 @@ export function summaryColumns( ); } -/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */ +/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지 · 사본 id(본체 = 구조물 id · 그룹 = `{id}__{그룹}`). */ export interface StationCombo { id: string; item: SummaryItem; structureId: string | null; + copyId: string | null; } /** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */ @@ -99,16 +131,17 @@ export function combosAt(things: readonly PlacedItem[], station: number): Statio const at = stationOf(thing); if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue; const own = readSummaryItem(thing.summary_item); - for (const item of [ - ...(own ? [own] : []), - ...Object.values(readGroupItems(thing.group_items)), + const structureId = thing.structure_id ?? null; + for (const [gid, item] of [ + ...(own ? [["", own] as const] : []), + ...Object.entries(readGroupItems(thing.group_items)), ]) { - const structureId = thing.structure_id ?? null; const key = `${structureId} ${comboId(item)}`; + const copyId = structureId && (gid ? `${structureId}${GROUP_JOIN}${gid}` : structureId); if (!seen.has(key)) { seen.add(key); - found.push({ id: comboId(item), item, structureId }); + found.push({ id: comboId(item), item, structureId, copyId }); } } } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index b3356214a..60dc1a94e 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -4,7 +4,7 @@ * 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD · * 여러 장이면 장 탭) + 면적산출. * - * 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만 + * 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만 * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ @@ -12,19 +12,11 @@ import "./B08_Quantity_UI_Drawing.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; -import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data"; -import { - PART_OF, - projectItems, - sameItem, - structureFolder, - type ProjectItemInfo, -} from "@ui/ui_template_item_picker_project"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +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, StationCombo } from "./B08_Quantity_Summary_Data"; +import type { ComboLabel, LinkedSnapshot, StationCombo } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -42,48 +34,21 @@ export interface SheetsRequest { /** 모달 제목에 붙는 측점 글 */ station: string; combos: StationCombo[]; - /** `comboId` → M02 줄 */ - rows: Map; + /** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */ + linked: Record; describe: (item: SummaryItem) => ComboLabel | null; } -/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */ -const loadCopies = (projectId: string): Promise => - projectItems(projectId) - .list() - .catch(() => []); - -function sourceOf( - request: SheetsRequest, - combo: StationCombo, - row: PickerRow, - copies: ProjectItemInfo[], -): { source: ItemSource; copy: boolean } { - const own = combo.structureId - ? copies.find((info) => info.폴더 === structureFolder(combo.structureId!)) - : undefined; - if (own && sameItem(own, row)) { - const items = projectItems(request.projectId); - return { - copy: true, - source: { - layer: "project", - projectId: request.projectId, - read: async (part) => { - const doc = await items.read(own.폴더, PART_OF[part]); - if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); - return { 문서: doc }; - }, - }, - }; - } - const layer = layerOf(row); +/** 사본 폴더 읽기 — 그 부분 파일이 없으면 던짐(카드가 「없음」). */ +function sourceOf(projectId: string, folder: string): ItemSource { + const items = projectItems(projectId); return { - copy: false, - source: { - layer, - projectId: null, - read: (part, column) => readTemplate(layer, part, column, null), + layer: "project", + projectId, + read: async (part) => { + const doc = await items.read(folder, PART_OF[part]); + if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); + return { 문서: doc }; }, }; } @@ -92,7 +57,6 @@ function sourceOf( function comboSection( request: SheetsRequest, combo: StationCombo, - copies: ProjectItemInfo[], createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards, handles: ItemCardsHandle[], ): HTMLElement { @@ -100,7 +64,7 @@ function comboSection( const name = label ? [label.group, label.name].filter(Boolean).join(" ") : combo.item.name || combo.item.key; - const row = request.rows.get(combo.id) ?? null; + const copy = combo.copyId ? request.linked[combo.copyId] : undefined; const section = document.createElement("section"); section.className = "b08-sheets__combo"; section.dataset.b08Combo = combo.id; @@ -109,27 +73,25 @@ function comboSection( title.textContent = name; title.title = summaryTooltip(combo.item); section.append(title); - if (!row?.열) { + if (!copy) { section.append(note(L("B08_Quantity_Drawing_Missing"))); return section; } - const picked = sourceOf(request, combo, row, copies); + const source = sourceOf(request.projectId, copy.폴더); const from = document.createElement("span"); from.className = "b08-sheets__from"; - from.textContent = L( - picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster", - ); + from.textContent = L("B08_Quantity_Sheets_FromCopy"); title.append(from); - if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); + if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); const cards = createItemCards({ - source: () => picked.source, + source: () => source, editable: () => false, onEdit: () => undefined, warnOnFail: () => false, // 못 읽으면 카드에 「없음」 }); handles.push(cards); section.append(cards.root); - void cards.load({ id: row.열, label: name }); + void cards.load({ id: copy.폴더, label: name }); return section; } @@ -151,14 +113,12 @@ export function openStructureSheets(request: SheetsRequest): void { body.append(note(L("B08_Quantity_Drawing_Loading"))); void (async () => { try { - const [{ createItemCards }, copies] = await Promise.all([ - import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - loadCopies(request.projectId), - ]); + const { createItemCards } = + await import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"); if (closed) return; body.replaceChildren( ...request.combos.map((combo) => - comboSection(request, combo, copies, createItemCards, handles), + comboSection(request, combo, createItemCards, handles), ), ); } catch (error) { diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4a4e13ff8..5038a7c92 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -4,7 +4,8 @@ * 표는 공용 많은 자료 표(`ui_template_data_table` · PLAN 70-2) — 머리 · 측점 열 고정. * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) - * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`) + * 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 없으면 그 시설은 없음 * 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격 * 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는 * 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로 @@ -15,8 +16,6 @@ import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; -import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; import { showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; @@ -28,10 +27,12 @@ import { summaryTooltip, type SummaryItem } from "../common_util/common_util_sum import { combosAt, comboId, + linkedOnly, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, + type LinkedSnapshot, type PlacedItem, type StationCombo, widthOf, @@ -72,6 +73,8 @@ interface PipeSummary { types: string[]; one_length_m: number | null; pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; } async function fetchPipeSummary(projectId: string): Promise { @@ -88,7 +91,7 @@ const stationKey = (station: number): number => Math.round(station * 100); const placesOf = (values: number[]): number => Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(M02 에 있으면 · 조합 잇기 98). */ +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { const top = summary.columns @@ -168,12 +171,12 @@ async function openSheets( projectId: string, station: string, combos: StationCombo[], - rows: Map, + linked: Record, describe: (item: SummaryItem) => ComboLabel | null, ): Promise { try { const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); - openStructureSheets({ projectId, station, combos, rows, describe }); + openStructureSheets({ projectId, station, combos, linked, describe }); } catch (error) { showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); } @@ -183,9 +186,8 @@ async function openSheets( export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pickerRows, pipes, interval, pipe] = await Promise.all([ + const [structures, pipes, interval, pipe] = await Promise.all([ fetchStructures(projectId), - loadPickerRows(), // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 fetchDetailPipePoints(projectId).then( (response) => response.pipe_points, @@ -195,28 +197,28 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 fetchPipeSummary(projectId).catch((error: unknown) => String(error)), ]); - const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); const describe = (item: SummaryItem): ComboLabel | null => { - const row = m02.get(comboId(item)); - if (!row) return null; - const [group = "", ...rest] = pathOf(row); - return { group, name: rest.join(" ") || group || row.key }; + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; }; - const comboPath = (id: string): string | null => { - const row = m02.get(id); - return row ? pathOf(row).join(" ") : null; - }; - const placed = pipes.map((point) => ({ - ...point, - structure_id: pipeStructureId(point.chainage_m), - })); - const things: PlacedItem[] = [...structures.structures, ...placed]; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + const placed = linkedOnly( + pipes.map((point) => ({ ...point, structure_id: pipeStructureId(point.chainage_m) })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; const stations = summaryStations(things); // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const saved = typeof pipe === "string" ? null : pipe; const served = new Set(saved?.types ?? []); const others = [ - ...structures.structures.filter((item) => !served.has(item.type_id)), + ...mine.filter((item) => !served.has(item.type_id)), ...placed.filter((point) => !served.has(point.facility ?? "pipe")), ]; const columns = summaryColumns(others, describe); @@ -231,7 +233,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin const draw = (): HTMLElement => stations.length ? buildTable(stations, columns, live(), comboPath, interval, (station, label) => - openSheets(projectId, label, combosAt(things, station), m02, describe), + openSheets(projectId, label, combosAt(things, station), linked, describe), ) : note(L("B08_Quantity_Summary_NoStation")); let table = draw(); diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 2270275bb..3e750d7cc 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -304,6 +304,33 @@ def complete_old(folder: Path) -> bool: return True +#: 「연결된 사본」 부분 이름 → 파일(있는 것만 `부분` 에 담음) +LINKED_PARTS = (("도면", "도면.json"), ("면적", "면적.json"), ("일위대가", LABOR_FILE)) + + +def linked_snapshots(project_root: str | Path) -> dict[str, dict[str, Any]]: + """연결된 사본 — 지정 ∩ 같은 항목 사본 `{구조물 id: {항목, 폴더, 부분, 경로}}`(PLAN 143-1). + + 구조물 id = `assignments` 키(관 지점 `pipe@측점` · 옵션 그룹 `{id}__{그룹}`) · 미지정 · 사본 없음 · + 다른 항목 사본은 안 담음(M02 `assign_items` 규칙) · 원본 층과 대조 안 함(빠른 읽기 · 쓰기 없음) · + `경로` = manifest 원본 경로(가져온 때 M02 구분 · 이름) · B08 · B09 공용. + """ + base = structures_dir(project_root) + found: dict[str, dict[str, Any]] = {} + for owner, item in assignments(project_root).items(): + folder = base / folder_name(owner) + manifest = read_json(folder / MANIFEST_NAME) + if not same_item(manifest, item): + continue + found[owner] = { + "항목": item, + "폴더": folder.name, + "부분": [part for part, name in LINKED_PARTS if (folder / name).is_file()], + "경로": list(manifest["원본"].get("경로") or []), + } + return found + + def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None: """그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음).""" if items_dir(project_root).is_dir(): diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index ec364dfaf..2ae572e72 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -8,6 +8,8 @@ 400 세월교 Φ1000×2련 월류 폭 12 · 유입 날개벽 · 유출 흙막이 찰(높이 빈칸 → 미입력) 3 + 4 500 물넘이 포장 월류 폭 8 · H=1.0 600 개거 + 노출형 횡단수로 · 700 맹암거(그룹 조합) +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 견본은 칸을 내는 잇기를 모두 두고 연결된 사본 = +지정 전부 · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -22,6 +24,7 @@ import pytest from B06_Section.B06_Section_Schema import CrossSectionPatch from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -36,7 +39,7 @@ def _item(key: str) -> dict: POINTS = [ - {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000100")}, { "chainage_m": 300.0, "facility": "box_culvert", @@ -50,7 +53,11 @@ POINTS = [ "outlet_revet_after_m": 5, }, "summary_item": _item("SS000101"), - "group_items": {"gboxbody": _item("SS000102")}, + "group_items": { + "gboxbody": _item("SS000102"), + "gboxin": _item("SS000106"), + "gboxout": _item("SS000107"), + }, }, { "chainage_m": 340.0, @@ -61,6 +68,8 @@ POINTS = [ "wing_in": "없음", "wing_out": "날개벽", }, + "summary_item": _item("SS000101"), + "group_items": {"gboxout": _item("SS000107")}, }, { "chainage_m": 400.0, @@ -77,21 +86,33 @@ POINTS = [ "outlet_revet_before_m": 3, "outlet_revet_after_m": 4, }, - "group_items": {"gford": _item("SS000103"), "gfordin": _item("SS000104")}, + "group_items": { + "gford": _item("SS000103"), + "gfordin": _item("SS000104"), + "gfordout": _item("SS000108"), + }, }, { "chainage_m": 500.0, "facility": "ford_pavement", "options": {"ford_width_m": 8, "ford_height_m": 1.0}, + "summary_item": _item("SS000109"), }, ] STRUCTURES = [ - {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 600}, + { + "structure_id": "s-ditch", + "type_id": "open_ditch", + "placement": "point", + "chainage_m": 600, + "summary_item": _item("SS000110"), + }, { "structure_id": "s-exposed", "type_id": "cross_drain_exposed", "placement": "point", "chainage_m": 600, + "summary_item": _item("SS000111"), }, { "structure_id": "s-ud", @@ -123,7 +144,8 @@ def out(tmp_path: Path) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_131_b08_material_fix.py b/resources/tester/test_131_b08_material_fix.py index fa1fb00d1..5f96af366 100644 --- a/resources/tester/test_131_b08_material_fix.py +++ b/resources/tester/test_131_b08_material_fix.py @@ -20,6 +20,7 @@ from test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts from B08_Quantity import B08_Quantity_Router_Material as router_mod from B08_Quantity import B08_Quantity_Router_Summary as summary_mod from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, finish +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition ROOT = Path(__file__).resolve().parents[2] @@ -107,7 +108,8 @@ def _ford_summary(tmp_path: Path, designs: list[dict]) -> dict: encoding="utf-8", ) names = definition.read()["종류"]["pipe"]["이름"] - return summary_mod.pipe_summary(root, designs, names, 6.0) + linked = {owner: {} for owner in snapshots.assignments(root)} # 143-1 지정 전부 사본 있음 + return summary_mod.pipe_summary(root, designs, names, 6.0, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py new file mode 100644 index 000000000..6fb1cda99 --- /dev/null +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +"""B08 구조물 집계표 — 조합 사본 없으면 없음(PLAN 143-1). + +견본(관 지점 넷 + 세월교 둘 + 개거 하나): + 100 관 Φ800 L16 · 사본 있음 · 유출 흙막이 잇기 사본 없음 → 관 칸만 · 유출 흙막이 칸 없음 + 200 관 Φ800 L18 · 지정 있으나 사본 폴더가 다른 항목 → 없음 + 300 관 Φ800 L20 · 지정 있으나 사본 폴더 없음 → 없음 + 400 관 Φ800 L22 · 지정 없음 → 없음 + 500 세월교 Φ1000×2련 · 본체 잇기(gford) 사본 있음 → 관 몫 · 월류 폭 셈 + 600 세월교 · 잇기 없음 → 관 몫 · pipes 에도 없음 + 700 개거 · 지정 없음 → 「사본 없는 시설」 에 셈 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +ROOT = Path(__file__).resolve().parents[2] + + +def _item(key: str) -> dict: + return {"layer": "system", "owner": None, "key": key, "name": key} + + +def _soil() -> dict: + return { + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(메)", + "outlet_revet_height_m": 2, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + } + + +POINTS = [ + { + "chainage_m": 100.0, + "options": {"pipe_diameter_mm": 800, **_soil()}, + "summary_item": _item("SS000001"), + "group_items": {"g9lq9uk": _item("SS000002")}, + }, + {"chainage_m": 200.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 300.0, "options": {"pipe_diameter_mm": 800}, "summary_item": _item("SS000001")}, + {"chainage_m": 400.0, "options": {"pipe_diameter_mm": 800}}, + { + "chainage_m": 500.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 2, "ford_width_m": 12}, + "group_items": {"gford": _item("SS000003")}, + }, + { + "chainage_m": 600.0, + "facility": "ford_bridge", + "options": {"pipe_diameter_mm": 1000, "pipe_count": 1, "ford_width_m": 8}, + }, +] +STRUCTURES = [ + {"structure_id": "s-ditch", "type_id": "open_ditch", "placement": "point", "chainage_m": 700}, +] +DESIGNS = [ + {"chainage_m": 100.0, "design": {"pipe_length_m": 16}}, + {"chainage_m": 200.0, "design": {"pipe_length_m": 18}}, + {"chainage_m": 300.0, "design": {"pipe_length_m": 20}}, + {"chainage_m": 400.0, "design": {"pipe_length_m": 22}}, + {"chainage_m": 500.0, "design": {"ford_pipe_length_m": 9}}, + {"chainage_m": 600.0, "design": {"ford_pipe_length_m": 7}}, +] + + +def _snapshot(root: Path, owner: str, item: dict, parts: tuple[str, ...] = ()) -> None: + folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) + folder.mkdir(parents=True) + origin = {**item, "경로": ["횡단배수", item["key"], "이름"]} + (folder / snapshots.MANIFEST_NAME).write_text( + json.dumps({"원본": origin}, ensure_ascii=False), encoding="utf-8" + ) + for name in parts: + (folder / name).write_text("{}", encoding="utf-8") + + +@pytest.fixture +def root(tmp_path: Path) -> Path: + found = tmp_path / "proj" + edits = found / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + points = [{**p, "source": "user"} for p in POINTS] + (edits / "pipe_points.json").write_text( + json.dumps({"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": 1, "structures": STRUCTURES}, ensure_ascii=False), + encoding="utf-8", + ) + _snapshot(found, "pipe@100.0", _item("SS000001"), ("도면.json", "일위대가.json")) + _snapshot(found, "pipe@200.0", _item("SS000009")) # 다른 항목 사본 + _snapshot(found, "pipe@500.0__gford", _item("SS000003")) + return found + + +def _run(root: Path) -> dict: + names = definition.read()["종류"]["pipe"]["이름"] + return summary.pipe_summary(root, DESIGNS, names, 6.0) + + +def _cell(out: dict, at: float, cid: str): + row = next((r for r in out["rows"] if abs(r["chainage_m"] - at) < 0.01), None) + return None if row is None else row["values"].get(cid) + + +def test_연결된_사본은_지정_교집합_같은_항목(root): + linked = snapshots.linked_snapshots(root) + assert set(linked) == {"pipe@100.0", "pipe@500.0__gford"} + one = linked["pipe@100.0"] + assert one["항목"]["key"] == "SS000001" and one["폴더"] == "pipe@100.0" + assert one["부분"] == ["도면", "일위대가"] # 있는 파일만 + assert one["경로"] == ["횡단배수", "SS000001", "이름"] + + +def test_사본_없는_칸은_안_셈(root): + out = _run(root) + assert _cell(out, 100, "pipe:len|800") == 16 + assert _cell(out, 100, "pipe:cp|800") == 2 + # 유출 흙막이 잇기는 사본 없음 → 그 칸만 없음(열도 안 섬) + assert not any(c["id"].startswith("pipe:revet") for c in out["columns"]) + # 다른 항목 사본 · 사본 없음 · 지정 없음 → 줄 없음 + assert [round(r["chainage_m"]) for r in out["rows"]] == [100, 500] + assert out["total"]["pipe:len|800"] == 16 + + +def test_세월교_관_몫은_본체_잇기_사본으로(root): + out = _run(root) + assert _cell(out, 500, "pipe:len|1000") == 18 # 9 × 2련 + assert _cell(out, 500, "pipe:ford|1000|2|width") == 12 + assert _cell(out, 600, "pipe:len|1000") is None + assert [(round(p["chainage_m"]), p["column"]) for p in out["pipes"]] == [ + (100, "pipe:cp|800"), + (500, "pipe:cp|1000"), + (500, "pipe:cp|1000"), + ] + width = next(c for c in out["columns"] if c["id"] == "pipe:ford|1000|2|width") + assert width["combos"] == ["system||SS000003"] + + +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 not any("관 연장 없음" in n for n in out["notes"]) + assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"} + + +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 + 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 + + +def test_화면_사본_있는_조합만_사본_id(): + """`linkedOnly` — 본체 · 그룹마다 사본 있는 것만 · 하나도 없으면 시설째 뺌 · `combosAt` 사본 id.""" + import re + import subprocess + + imports = re.compile(r"^import[^;]*;\n", re.MULTILINE) + sources = [ + ROOT / "common_util" / "common_util_summary_item.ts", + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Data.ts", + ] + things = [ + {"structure_id": "s1", "chainage_m": 40, "summary_item": _item("SS1"), + "group_items": {"g1": _item("SS3"), "g2": _item("SS4")}}, + {"structure_id": "s2", "chainage_m": 80, "summary_item": _item("SS2")}, + ] # fmt: skip + linked = {"s1__g1": {}, "s1": {}} + body = ( + f"\nconst kept = linkedOnly({json.dumps(things)}, {json.dumps(linked)});" + "\nconsole.log(JSON.stringify([kept.map((t) => [t.structure_id, Object.keys(t.group_items)])," + " combosAt(kept, 40).map((c) => c.copyId)]));" + ) + script = "\n".join(imports.sub("", p.read_text(encoding="utf-8")) for p in sources) + body + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [[["s1", ["g1"]]], ["s1", "s1__g1"]] diff --git a/resources/tester/test_61_2_b08_structure_sheets.py b/resources/tester/test_61_2_b08_structure_sheets.py index 05333c28e..a098d8555 100644 --- a/resources/tester/test_61_2_b08_structure_sheets.py +++ b/resources/tester/test_61_2_b08_structure_sheets.py @@ -32,10 +32,14 @@ def test_read_only(): assert "editable: () => false" in DRAWING -def test_copy_first_then_master(): - # 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층 - assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING - assert "readTemplate(layer, part, column, null)" in DRAWING +def test_copy_only(): + # 143-1 — 연결된 사본만(그룹은 `{id}__{그룹}` 사본) · 없으면 「사본 없음」 · M02 층 폴백 없음 + assert ( + "request.linked[combo.copyId]" in DRAWING + and "sourceOf(request.projectId, copy.폴더)" in DRAWING + ) + assert "readTemplate" not in DRAWING and "FromMaster" not in DRAWING + assert "사본 없음" in LOCALE def test_locale_keys_exist(): diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index feaa73c5a..c65fb5991 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -7,6 +7,8 @@ 78+7 Φ800 L18 · 집수정 ㄷ(U) · 유출 메 H2.0 10 m · 메붙임 20 ㎡ → 커플링 2 84+0 Φ800 L16 · 집수정 ㄴ(L) · 유출 메 H2.0 → 커플링 올림(16/6)−1 = 2 · 1본 8 m 면 1 89+10 Φ800 L(없음) · 유출 높이 빈칸 → 관경 최소 1.8 · 집수정 기록 없음 → 알림 +143-1 — 칸은 그 칸을 낸 조합 사본이 있을 때만 셈: 픽스처는 관 · 유입 · 유출 · 흙막이 1 · 돌붙임 1 을 +모두 잇고 연결된 사본 = 지정 전부(`_run`) · 사본 없으면 없음은 test_143_1 """ from __future__ import annotations @@ -17,12 +19,15 @@ from pathlib import Path import pytest from B08_Quantity import B08_Quantity_Router_Summary as summary +from M02_MasterTemplete import M02_Item_Snapshot as snapshots from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition COMBO = {"layer": "system", "owner": None, "key": "SS000011", "name": "횡단배수 배관"} OUT = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "기슭막이 메쌓기"} WALL = {"side": "outlet", "key": "extra0"} +#: 컨테이너 잇기 전부(143-1 — 잇지 않은 칸은 안 셈) · 흙막이 칸 조합 = OUT +LINKED = {"gnbf9m9": COMBO, "g9lq9uk": OUT, "gsoiladd1": OUT, "gchuteadd1": COMBO} def _revet(side: str, form: str, height: float | None) -> dict: @@ -101,7 +106,15 @@ def root(tmp_path: Path) -> Path: found = tmp_path / "proj" edits = found / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True) - points = [{**p, "source": "user"} for p in POINTS] + points = [ + { + **p, + "source": "user", + "summary_item": COMBO, + "group_items": {**LINKED, **(p.get("group_items") or {})}, + } + for p in POINTS + ] (edits / "pipe_points.json").write_text( json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8" ) @@ -110,7 +123,8 @@ def root(tmp_path: Path) -> Path: def _run(root: Path, one_m: float | None = 6.0) -> dict: names = definition.read()["종류"]["pipe"]["이름"] - return summary.pipe_summary(root, DESIGNS, names, one_m) + linked = {owner: {} for owner in snapshots.assignments(root)} # 지정 전부 사본 있음 + return summary.pipe_summary(root, DESIGNS, names, one_m, linked=linked) def _cell(out: dict, at: float, cid: str): diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 8f2f5cab4..962572564 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -28,7 +28,6 @@ export const ui_locales_b4 = { B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"], B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"], B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"], - B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"], B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"], B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"], B08_Quantity_Drawing_Failed: [ @@ -36,15 +35,15 @@ export const ui_locales_b4 = { "Failed to load structure sheets", ], B08_Quantity_Drawing_Missing: [ - "M02 에 없는 조합입니다 — 도면 · 면적산출을 보일 수 없습니다.", - "This combination is not in M02 — no drawing or area sheet to show.", + "사본 없음 — B05 · B06 좌측 패널에서 조합 연결 · [저장] 뒤 보입니다.", + "No project copy — link the combination in the B05 · B06 panel and save.", ], B08_Quantity_Drawing_NoDrawing: [ - "이 조합은 도면이 없습니다 — M02 구조물 조합에서 그립니다.", - "This combination has no drawing — draw it in M02 structure combinations.", + "이 조합 사본은 도면이 없습니다 — M02 구조물 조합에서 그린 뒤 [재적용].", + "This copy has no drawing — draw it in M02 structure combinations, then reapply.", ], B08_Quantity_Summary_Station: ["측점", "Station"], - B08_Quantity_Summary_MissingGroup: ["M02 에 없음", "Not in M02"], + B08_Quantity_Summary_MissingGroup: ["경로 없음", "No path"], B08_Quantity_Summary_Loading: ["구조물 집계표를 불러오는 중…", "Loading the structure summary…"], B08_Quantity_Summary_Failed: [ "구조물 집계표를 불러오지 못했습니다",