diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index cf257786b..aebd1a97f 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -17,7 +17,7 @@ import { API_SURFACE_BUILD_TIMEOUT_MS, API_TIMEOUT_MS, } from "@config/config_frontend"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; /** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */ export interface SurfaceAnalyzeRequest { @@ -494,6 +494,8 @@ export interface DetailPipePoint { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ + group_items?: GroupItems | null; } /** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 98248d1c7..02146289b 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -12,7 +12,7 @@ * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; /** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */ @@ -77,6 +77,15 @@ export interface StructureType { design_owner: string | null; reference_only: boolean; enabled: boolean; + /** 옵션 그룹(M02 PLAN 46) — 폼의 보조 컨테이너(제목 = name) · 안의 옵션 키 · 빈 그룹도 됨. */ + groups?: StructureOptionGroup[]; +} + +/** 옵션 그룹 한 개 — 서버가 M02 「구조물 옵션 보이기」 그룹 표로 채움. */ +export interface StructureOptionGroup { + id: string; + name: string; + options: string[]; } export interface StructureInstance { @@ -94,6 +103,8 @@ export interface StructureInstance { geometry: Record | null; /** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ + group_items?: GroupItems | null; } interface StructureTypesResponse { diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 42ea75e91..b98fe87e4 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -15,7 +15,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from common_util.common_util_summary_item import parse_summary_item +from common_util.common_util_summary_item import parse_group_items, parse_summary_item # 배치형태: 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). STRUCTURE_PLACEMENTS = ("point", "interval", "site") @@ -160,12 +160,19 @@ class StructureInstance(BaseModel): geometry: dict[str, Any] | None = None # 구조물 집계표 항목(PLAN 21-1) — 없으면 null(옛 파일 호환) · [저장] 뒤 프로젝트 스냅샷 summary_item: dict[str, Any] | None = None + # 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item` + group_items: dict[str, Any] | None = None @field_validator("summary_item") @classmethod def validate_summary_item(cls, value: Any) -> dict[str, Any] | None: return parse_summary_item(value) + @field_validator("group_items") + @classmethod + def validate_group_items(cls, value: Any) -> dict[str, Any] | None: + return parse_group_items(value) + @model_validator(mode="after") def validate_placement_fields(self) -> "StructureInstance": if self.placement == "interval": diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 3bc90bf7b..7cd62ad23 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -59,7 +59,12 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import { + hasLinks, + linkFields, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden"; @@ -74,11 +79,13 @@ export interface FacilityAttributes { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖) — null/없음 = 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */ + group_items?: GroupItems | null; } /** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ function hasExtras(attributes: FacilityAttributes): boolean { - if (attributes.summary_item) return true; // 집계 항목만 지정한 맨 배관도 보관한다 + if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다 if (attributes.facility !== "pipe") return true; if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true; return !!attributes.options && Object.keys(attributes.options).length > 0; @@ -122,13 +129,13 @@ export function createFacilityStore() { end_m?: number; options?: Record; summary_item?: SummaryItem | null; + group_items?: GroupItems | null; }>, ): void { entries = points .map((point) => { // 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔 // 값을 이어받았는데, 횡단 보기에서 [해제]한 초안이 응답으로 돌아올 때 옛 지정이 되살아났다. - const summary = point.summary_item ?? null; return { chainage: point.chainage_m, attributes: { @@ -136,7 +143,7 @@ export function createFacilityStore() { start_m: point.start_m, end_m: point.end_m, options: point.options, - ...(summary ? { summary_item: summary } : {}), + ...linkFields(point), } as FacilityAttributes, }; }) @@ -176,9 +183,9 @@ export function createFacilityStore() { return points.map((point, index) => { const pick = picks[index]; if (pick === null) return { ...point }; - const { summary_item: summary, ...rest } = entries[pick].attributes; + const { summary_item: _s, group_items: _g, ...rest } = entries[pick].attributes; // 미지정(null)은 칸을 안 싣는다 — 서버가 모르는 칸을 거절하는 동안에도 계산이 안 깨짐. - return { ...point, ...rest, ...(summary ? { summary_item: summary } : {}) }; + return { ...point, ...rest, ...linkFields(entries[pick].attributes) }; }); }, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 56e623b29..9a33297c5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -84,6 +84,8 @@ interface MergeableAttributes { options?: Record; /** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */ summary_item?: unknown; + /** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */ + group_items?: unknown; } /** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */ @@ -99,5 +101,14 @@ export function mergeFacilityOptions( next.summary_item === undefined && previous.summary_item !== undefined ? { summary_item: previous.summary_item } : {}; - return { ...next, ...summary, options: Object.keys(options).length ? options : undefined }; + const groups = + next.group_items === undefined && previous.group_items !== undefined + ? { group_items: previous.group_items } + : {}; + return { + ...next, + ...summary, + ...groups, + options: Object.keys(options).length ? options : undefined, + }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index c6f308fff..140f8d9e8 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -7,7 +7,7 @@ * 그대로 다시 내보내므로 기존 import 경로는 유효하다. * ========================================================================== */ -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -67,6 +67,8 @@ export interface DrainagePipeReport { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; } export interface DrainagePanelCallbacks { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index a329c75ba..d5596c4ca 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -34,6 +34,7 @@ export function buildPipeReports( source: (pipe.reason || "user") as PipeSource, options: attributes?.options, summary_item: attributes?.summary_item, + group_items: attributes?.group_items, }; }); } diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index 0ece33c2a..a5380a414 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -6,7 +6,7 @@ * 이름·필드·주석은 그대로이고, 패널이 다시 `export` 해 옛 임포트 경로도 그대로 동작한다. * ========================================================================== */ -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { IrregularStation } from "./B05_Profile_UI_IrregularStations"; @@ -27,6 +27,8 @@ export interface RouteProfilePanelCallbacks { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; }>, ) => void; /** 테이블에서 구조물 라인을 끌어 옮김. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 515065284..980e75b36 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -23,6 +23,7 @@ import { } from "./B05_Profile_UI_Structures_Fields"; import { createFacilityOptionsForm } from "./B05_Profile_UI_Drainage_Facility"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { createGroupBoxes, type GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; export interface StructuresFormElements { root: HTMLElement; @@ -52,6 +53,8 @@ export interface StructuresFormElements { list: HTMLElement; /** 옵션 · 시설 서브폼 아래 「집계 항목」 줄(PLAN 21-4) — 이름 · [고르기] · [해제]. */ summary: SummaryRow; + /** 본체 집계 항목 아래 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 · 집계 항목. */ + groupBoxes: GroupBoxes; } /** 폼 뼈대를 세운다. 값 채우기·이벤트 배선은 호출한 쪽(패널)이 한다. */ @@ -144,6 +147,7 @@ export function buildStructuresForm(options: { // 집계 항목 줄 — 종류를 고른 뒤에만 보인다(패널이 `syncPlacementFields` 에서 켠다). const summary = buildSummaryRow(); summary.root.hidden = true; + const groupBoxes = createGroupBoxes(); body.append( typeRow, @@ -153,6 +157,7 @@ export function buildStructuresForm(options: { optionRow, facilityOptions.root, summary.root, + groupBoxes.root, actions, ); @@ -178,5 +183,6 @@ export function buildStructuresForm(options: { resetButton, list, summary, + groupBoxes, }; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts new file mode 100644 index 000000000..c4f75cb0d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Groups.ts + * 구조물 폼의 **옵션 그룹 보조 컨테이너**(PLAN 46-3) — M02 「구조물 옵션 보이기」에서 종류마다 만든 + * 그룹(`StructureType.groups`)마다 상자 하나(제목 = 그룹 이름) · 그 그룹 옵션 칸 · 「집계 항목」 줄. + * + * · 칸은 새로 그리지 않고 **옮긴다** — 레지스트리 옵션 칸 · 계곡 통과 시설 칸이 붙인 + * `data-option-keys` 표식으로 찾아 상자 안으로 옮기고, 제자리에는 표지(주석 노드)를 둔다. 값 읽기 · + * 이벤트는 칸 참조 그대로라 안 바뀜. 다시 세울 때(`render`)마다 먼저 제자리로 되돌림. + * · 키가 여럿인 칸(본체 규격 · 유입구 구조)은 **키가 다 그 그룹일 때만** 옮김(숨김 규칙과 같음 · + * 독립 기슭막이 채널 키는 `hiddenFormKeys` 로 넓힘). + * · 그룹 연결 값(`{그룹 id: 참조}`)은 정의가 사라진 그룹 것도 그대로 쥠(값 보존 · 경고 한 줄). + * · 저장은 패널이 한다(`group_items` 칸) — 여기는 값만 쥠. + * ========================================================================== */ + +import { openItemPicker } from "@ui/ui_template_item_picker"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; +import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; +import { + GROUP_JOIN, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; + +export interface GroupBoxes { + /** 상자를 담는 자리 — 폼이 본체 「집계 항목」 줄 아래에 붙임. */ + root: HTMLElement; + /** 종류 · 칸에 맞춰 상자를 다시 세움 — `scopes` 안 표식 칸을 그룹 상자로 옮김. */ + render: (type: StructureType | null, scopes: HTMLElement[], facility: string | null) => void; + get: () => GroupItems; + /** 값만 바꿔 그림 — 저장 · 알림은 안 함(폼 불러오기 · 모달 결과가 씀). */ + set: (items: GroupItems) => void; + /** 한 그룹 값 — null = 해제. */ + setOne: (groupId: string, item: SummaryItem | null) => void; + /** [고르기] · [해제] 배선 — 패널이 한 번 부른다. */ + bind: (handlers: { pick: (groupId: string) => void; clear: (groupId: string) => void }) => void; +} + +export function createGroupBoxes(): GroupBoxes { + const root = document.createElement("div"); + root.className = "b05-structure__optgroups"; + root.dataset.b05Groups = ""; + root.hidden = true; + let items: GroupItems = {}; + let handlers: { pick: (id: string) => void; clear: (id: string) => void } | null = null; + const rows = new Map(); + /** 옮긴 칸 · 제자리 표지 */ + const moved: Array<{ node: HTMLElement; mark: Comment }> = []; + /** 칸이 다 빠져 가린 줄 · 소그룹 상자 */ + const emptied: HTMLElement[] = []; + /** 그룹 상자 몸통 — 옮겨 온 칸이 원래 자리처럼 잠기게(측점 확정 전) */ + let bodies: HTMLElement[] = []; + let lockSource: HTMLElement | null = null; + const syncLock = (): void => { + const locked = !!lockSource?.inert; + bodies.forEach((body) => { + body.inert = locked; + body.classList.toggle("is-locked", locked); + }); + }; + const lockWatch = typeof MutationObserver !== "undefined" ? new MutationObserver(syncLock) : null; + + const restore = (): void => { + for (const { node, mark } of moved.splice(0)) { + if (mark.isConnected) mark.replaceWith(node); + else node.remove(); // 원래 자리가 다시 그려졌음(레지스트리 칸) — 옛 칸은 버림 + } + emptied.splice(0).forEach((node) => node.classList.remove("is-group-emptied")); + }; + + const paintStale = (known: Set): HTMLElement | null => { + const stale = Object.keys(items).filter((id) => !known.has(id)); + if (!stale.length) return null; + const note = document.createElement("p"); + note.className = "b05-structure__owner-note"; + note.textContent = `정의가 사라진 그룹 연결 ${stale.length}개 — 값만 남음(집계표에는 안 들어감)`; + return note; + }; + + return { + root, + render(type, scopes, facility) { + restore(); + rows.clear(); + const groups = type?.groups ?? []; + const boxes: HTMLElement[] = []; + bodies = []; + // 「구조물 옵션 보이기」에서 끈 칸 — 시설 폼 밖으로 옮긴 칸도 같은 규칙으로 가림(Hidden 과 같은 식) + const off = hiddenFormKeys( + facility, + (type?.options ?? []).filter((o) => o.visible === false).map((o) => o.key), + ); + for (const group of groups) { + const keys = hiddenFormKeys(facility, group.options); + const box = document.createElement("fieldset"); + box.className = "b05-structure__group b05-structure__optgroup"; + box.dataset.b05Group = group.id; + const legend = document.createElement("legend"); + legend.textContent = group.name; + const body = document.createElement("div"); + body.className = "b05-structure__grid"; + for (const scope of scopes) { + const marked = [...scope.querySelectorAll("[data-option-keys]")]; + const fits = marked.filter((node) => + (node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)), + ); + // 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴 + for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) { + const parent = node.parentElement; + const mark = document.createComment(`옵션 그룹 ${group.id}`); + node.before(mark); + body.append(node); + moved.push({ node, mark }); + const own = (node.dataset.optionKeys ?? "").split(" "); + node.classList.toggle( + "is-option-hidden", + own.every((key) => off.has(key)), + ); + // 칸이 다 빠진 줄 · 소그룹 상자는 가림(빈 줄 · 빈 상자 제목이 안 남게) + for (let at = parent; at && at !== scope; at = at.parentElement) { + if (at.querySelector("input, select, textarea")) break; + at.classList.add("is-group-emptied"); + emptied.push(at); + } + } + } + if (!body.childElementCount) { + const none = document.createElement("p"); + none.className = "b05-structure__owner-note"; + none.textContent = "옵션 없음 — 집계 항목만 지정"; + body.append(none); + } + const row = buildSummaryRow(); + row.set(items[group.id] ?? null); + row.bind({ + pick: () => handlers?.pick(group.id), + clear: () => handlers?.clear(group.id), + }); + rows.set(group.id, row); + box.append(legend, body, row.root); + boxes.push(box); + bodies.push(body); + } + const stale = type ? paintStale(new Set(groups.map((g) => g.id))) : null; + root.replaceChildren(...boxes, ...(stale ? [stale] : [])); + root.hidden = !type || (!boxes.length && !stale); + // 잠금은 계곡 통과 시설 서브폼(`inert`)을 따름 — 레지스트리 칸은 칸마다 disabled 라 그대로 + const facilityRoot = scopes.find((scope) => + scope.classList.contains("b05-drainage__facility"), + ); + if (facilityRoot && facilityRoot !== lockSource) { + lockSource = facilityRoot; + lockWatch?.observe(facilityRoot, { attributes: true, attributeFilter: ["inert"] }); + } + syncLock(); + }, + get: () => ({ ...items }), + set(next) { + items = { ...next }; + for (const [id, row] of rows) row.set(items[id] ?? null); + }, + setOne(groupId, item) { + if (item) items = { ...items, [groupId]: item }; + else { + const { [groupId]: _gone, ...rest } = items; + items = rest; + } + rows.get(groupId)?.set(item); + }, + bind(next) { + handlers = next; + }, + }; +} + +/** 본체 · 그룹 「집계 항목」 [고르기] · [해제] 배선 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 + * 고치는 중이면 곧바로 반영(새 항목은 종전대로 [추가]가 확정) · 안 고른 채 닫으면 그대로. + * 복사본은 구조물마다(고치는 구조물 id · 관 지점 = `pipe@측점`) · 그룹은 `{id}__{그룹 id}` · + * 추가 전이면 null(원본 보기만). */ +export function bindSummaryPickers(opts: { + summary: SummaryRow; + groups: GroupBoxes; + projectId: string | null; + editingId: () => string | null; + selectedPipeChainage: () => number | null; + commitSummary: () => void; +}): void { + const base = (): string | null => { + const pipe = opts.selectedPipeChainage(); + return opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null); + }; + // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 + const pick = (current: SummaryItem | null, structureId: string | null) => + openItemPicker({ current, canCopy: true, projectId: opts.projectId, structureId }); + opts.summary.bind({ + pick: () => + void pick(opts.summary.get(), base()).then((result) => { + if (result.action === "cancel") return; + opts.summary.set(result.action === "apply" ? result.item : null); + opts.commitSummary(); + }), + clear: () => { + opts.summary.set(null); + opts.commitSummary(); + }, + }); + opts.groups.bind({ + pick: (groupId) => { + const id = base(); + void pick( + opts.groups.get()[groupId] ?? null, + id ? `${id}${GROUP_JOIN}${groupId}` : null, + ).then((result) => { + if (result.action === "cancel") return; + opts.groups.setOne(groupId, result.action === "apply" ? result.item : null); + opts.commitSummary(); + }); + }, + clear: (groupId) => { + opts.groups.setOne(groupId, null); + opts.commitSummary(); + }, + }); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 357b6e80a..63c0470d9 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -24,8 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field } from "./B05_Profile_UI_Structures_Fields"; import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; -import { openItemPicker } from "@ui/ui_template_item_picker"; -import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import { bindSummaryPickers } from "./B05_Profile_UI_Structures_Groups"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -91,7 +90,15 @@ export function createStructuresSection( resetButton, list, summary, + groupBoxes, } = form; + /** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */ + const regroup = (): void => + groupBoxes.render( + currentType(), + [optionRow, facilityOptions.root], + facilityFormKind(currentType()), + ); /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 @@ -242,6 +249,7 @@ export function createStructuresSection( kind, (currentType()?.options ?? []).filter((o) => !isOptionVisible(o)).map((o) => o.key), ); + regroup(); syncOptionLock(); } @@ -312,6 +320,7 @@ export function createStructuresSection( /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ function renderOptionFields(values: Record = {}): void { renderOptionFieldsInto(optionContext, values); + regroup(); } /** 고른 구조물군의 타입을 종류 목록에 채운다 — A군은 계곡 통과 시설(managed_by)도 @@ -491,31 +500,18 @@ export function createStructuresSection( }, summary: () => summary.get(), setSummary: (value) => summary.set(value), + groupItems: () => groupBoxes.get(), + setGroupItems: (value) => groupBoxes.set(value), }; bindStructuresEvents(panelContext); - // 집계 항목 — [고르기]는 21-5 모달, 결과는 폼 값으로만 두고 고치는 중이면 곧바로 반영한다 - // (새 항목은 종전대로 [추가]가 확정). 안 고른 채 닫으면 그대로. - summary.bind({ - pick: () => { - // canCopy — 개인 층에 쓸 수 없는 사용자의 복사는 서버가 막은 까닭을 모달이 그대로 보임 - void openItemPicker({ - current: summary.get(), - canCopy: true, - projectId: sectionOptions.projectId ?? null, - // 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만) - structureId: - editingId ?? - (selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null), - }).then((result) => { - if (result.action === "cancel") return; - summary.set(result.action === "apply" ? result.item : null); - commitSummary(); - }); - }, - clear: () => { - summary.set(null); - commitSummary(); - }, + // 집계 항목(본체 · 옵션 그룹) — [고르기]는 21-5 모달 · 배선은 `_Structures_Groups` + bindSummaryPickers({ + summary, + groups: groupBoxes, + projectId: sectionOptions.projectId ?? null, + editingId: () => editingId, + selectedPipeChainage: () => selectedPipeChainage, + commitSummary, }); body.insertBefore(field("메모", memoField), actions); syncButtons(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 7509b7ef9..86196870c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,7 +18,12 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; -import { readSummaryItem, type SummaryItem } from "../common_util/common_util_summary_item"; +import { + readGroupItems, + readSummaryItem, + type GroupItems, + type SummaryItem, +} from "../common_util/common_util_summary_item"; import type { PipeFacilityItem, StructuresCallbacks, @@ -69,6 +74,15 @@ export interface StructuresCommitContext { /** 폼의 「집계 항목」 값(PLAN 21-4) — 저장은 옵션 밖 `summary_item` 칸으로 나간다. */ summary: () => SummaryItem | null; setSummary: (value: SummaryItem | null) => void; + /** 옵션 그룹 보조 컨테이너마다 고른 항목(PLAN 46-3) — 저장은 `group_items` 칸. */ + groupItems: () => GroupItems; + setGroupItems: (value: GroupItems) => void; +} + +/** 저장할 그룹 연결 — 고른 것이 있을 때만 칸을 실음(빈 묶음 = 칸 없음). */ +function groupField(ctx: StructuresCommitContext): { group_items?: GroupItems } { + const items = ctx.groupItems(); + return Object.keys(items).length ? { group_items: items } : {}; } export function loadForm(ctx: StructuresCommitContext, target: StructureInstance | null): void { @@ -91,6 +105,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = target.memo ?? ""; ctx.renderOptionFields(target.options); ctx.setSummary(readSummaryItem(target.summary_item)); + ctx.setGroupItems(readGroupItems(target.group_items)); // 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된 // 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19). if (target.start_m != null && target.end_m != null && target.end_m > target.start_m) { @@ -119,6 +134,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.memoField.value = ""; ctx.renderOptionFields(); ctx.setSummary(null); + ctx.setGroupItems({}); ctx.syncFacilityForm(); } [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); @@ -166,6 +182,7 @@ export function loadPipeForm(ctx: StructuresCommitContext, pipe: PipeFacilityIte ctx.memoField.value = ""; ctx.syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null); ctx.setSummary(readSummaryItem(pipe.summary_item)); + ctx.setGroupItems(readGroupItems(pipe.group_items)); [ctx.anchorFields, ctx.startFields, ctx.endFields].forEach((fields) => fields.clearInvalid()); ctx.syncPlacementFields(); ctx.syncButtons(); @@ -201,6 +218,9 @@ export function commitSummaryOnly(ctx: StructuresCommitContext): boolean { const summary = ctx.summary(); if (summary) next.summary_item = summary; else delete next.summary_item; + const groups = groupField(ctx); + if (groups.group_items) next.group_items = groups.group_items; + else delete next.group_items; ctx.structures[index] = next; emit(ctx); return true; @@ -225,6 +245,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis if (Object.keys(options).length) attributes.options = options; // 집계 항목 — 미지정(null)도 그대로 실어 옛 지정을 풀 수 있게 한다. attributes.summary_item = ctx.summary(); + attributes.group_items = groupField(ctx).group_items ?? null; // 빈 것도 실어 옛 지정을 풂 if (ctx.tempPipeChainage() !== null) { // 임시 배치 확정([추가]) — 관은 이미 미리보기로 들어가 있으니 옵션·위치만 반영. const from = ctx.tempPipeChainage() as number; @@ -306,6 +327,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis end_m: end, options: readOptions(ctx), ...(summary ? { summary_item: summary } : {}), + ...groupField(ctx), memo: ctx.memoField.value.trim(), placement_source: "manual" as const, status: "draft" as const, @@ -347,6 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis }; // 해제 = 칸을 지움(옛 지정이 `...previous` 로 살아 오지 않게) if (!summary) delete next.summary_item; + if (!groupField(ctx).group_items) delete next.group_items; ctx.structures[index] = next; } records.slice(1).forEach((record) => ctx.structures.push({ ...record, structure_id: null })); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 6aad12385..de86abde6 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -72,7 +72,9 @@ export function renderOptionFields( input.classList.add("is-locked"); input.title = "지금은 고를 수 없는 항목입니다."; } - optionRow.append(field(label, input, option.default_basis)); + const wrap = field(label, input, option.default_basis); + wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) + optionRow.append(wrap); input.addEventListener("change", () => ctx.liveCommit()); optionInputs.push({ key: option.key, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 03a7afb73..ff1daad7a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -19,7 +19,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; -import type { SummaryItem } from "../common_util/common_util_summary_item"; +import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -51,6 +51,8 @@ export interface PipeFacilityItem { options?: Record; /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ summary_item?: SummaryItem | null; + /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */ + group_items?: GroupItems | null; } /** 관 지점 정본 한 건 → 목록·폼 항목. 집계 항목(21-1)도 옮긴다 — 안 옮기면 목록 색 · 폼이 잃는다. */ @@ -69,6 +71,7 @@ export function pipeToFacilityItem( design_flow_m3s: pipe.design_flow_m3s, options: pipe.options, summary_item: pipe.summary_item, + group_items: pipe.group_items, }; } diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index d1a617cc5..43b877db1 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -489,3 +489,34 @@ .b05-drainage__facility .is-option-hidden { display: none; } + +/* 옵션 그룹 보조 컨테이너(PLAN 46-3) — 그룹마다 상자 하나 · 옮겨 온 칸 · 「집계 항목」 줄 */ +.b05-structure__optgroups { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__optgroups[hidden], +.is-group-emptied { + display: none; +} + +.b05-structure__optgroup { + display: flex; + flex-direction: column; + gap: 6px; +} + +.b05-structure__optgroup > legend { + font-weight: 600; + opacity: 1; +} + +.b05-structure__optgroup .is-option-hidden { + display: none; +} + +.b05-structure__optgroup > .b05-structure__grid.is-locked { + opacity: 0.5; +} diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index bf452c50f..41eb873fd 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -31,7 +31,11 @@ import { import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { mergeFacilityOptions } from "../B05_Profile/B05_Profile_UI_Drainage_Facility_Merge"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { readSummaryItem } from "../common_util/common_util_summary_item"; +import { + linkFields, + readGroupItems, + readSummaryItem, +} from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, @@ -180,6 +184,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc source: "user", options: attributes.options ?? {}, summary_item: attributes.summary_item ?? null, + group_items: attributes.group_items ?? null, }); pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m); currentChainageM = at; @@ -197,13 +202,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M) movePipeTo(fromChainageM, toChainageM); - if (attributes.summary_item !== undefined) { + if (attributes.summary_item !== undefined || attributes.group_items !== undefined) { // 옮긴 뒤 자리(toChainageM)에서 찾는다 — 안 옮겼으면 from 과 0.51m 안이라 같은 관이다. const mine = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - toChainageM) < PIPE_MATCH_M, ); - if (mine && (mine.summary_item ?? null)?.key !== attributes.summary_item?.key) { + const before = mine ? JSON.stringify(linkFields(mine)) : ""; + if (mine && attributes.summary_item !== undefined) mine.summary_item = attributes.summary_item; + if (mine && attributes.group_items !== undefined) + mine.group_items = attributes.group_items; + if (mine && JSON.stringify(linkFields(mine)) !== before) { persistPipes(); section.setPipeFacilities(pipeFacilities); deps.onStructuresChanged?.(); @@ -294,6 +303,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc end_m: pipe.end_m, source: pipe.source, summary_item: readSummaryItem(pipe.summary_item), + group_items: readGroupItems(pipe.group_items), // 폼 기본값 = 조정창이 쓰던 값(횡단 스펙). 저장된 옵션이 있으면 그쪽이 이긴다. options: withSpecDefaults( pipe.options, @@ -374,7 +384,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}), ...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}), ...(pipe.options ? { options: pipe.options } : {}), - ...(pipe.summary_item ? { summary_item: pipe.summary_item } : {}), + ...linkFields(pipe), })), ); deps.onPipesWritten?.(); diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index 44e7eeaa6..d7e67022f 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -28,7 +28,7 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_json import atomic_write_json -from common_util.common_util_summary_item import snapshot_folder_name +from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Source import ( LABOR_FILE, @@ -48,8 +48,11 @@ MANIFEST_NAME = "manifest.json" VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}} PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}} LOCAL_FILES = (VALUES_FILE, PICKS_FILE) -#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` -FOLDER_PATTERN = re.compile(r"^(?:pipe@\d+(?:\.\d+)?|[0-9A-Za-z][0-9A-Za-z_-]*)$") +#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` · 옵션 그룹(PLAN 46)은 +#: 뒤에 `__{그룹 id}` +FOLDER_PATTERN = re.compile( + r"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{1,16})?|[0-9A-Za-z][0-9A-Za-z_-]*)$" +) def structures_dir(project_root: str | Path) -> Path: @@ -130,15 +133,22 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic def assignments(project_root: str | Path) -> dict[str, dict[str, Any]]: - """`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만.""" + """`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만 · + 옵션 그룹(PLAN 46)에 고른 항목은 `{구조물 id}__{그룹 id}` 로 따로(복사본도 따로).""" found: dict[str, dict[str, Any]] = {} _, structures = load_structures(str(project_root)) - for index, structure in enumerate(structures): - if structure.summary_item: - found[structure.structure_id or f"#{index}"] = structure.summary_item - for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): - if point.summary_item: - found[f"pipe@{round(float(point.chainage_m), 3)}"] = point.summary_item + owners: list[tuple[str, Any]] = [ + (s.structure_id or f"#{index}", s) for index, s in enumerate(structures) + ] + owners += [ + (f"pipe@{round(float(p.chainage_m), 3)}", p) + for p in read_pipe_points_file(pipe_points_path_in(Path(project_root))) + ] + for owner, record in owners: + if record.summary_item: + found[owner] = record.summary_item + for gid, item in (record.group_items or {}).items(): + found[f"{owner}{GROUP_JOIN}{gid}"] = item return found diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index b8f71e67f..a55dc4341 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -110,6 +110,7 @@ def collect_items( "options": _with_defaults(definition, dict(item.options or {})), "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", + "group_items": item.group_items, } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): @@ -126,9 +127,34 @@ def collect_items( "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, "summary_item": point.summary_item, "복사본": f"pipe@{round(chainage, 3)}", + "group_items": point.group_items, } ) - return items + return _with_group_items(items) + + +def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """옵션 그룹(M02 PLAN 46)마다 한 건 더 — 같은 구조물 · 옵션 · 자리에 그 그룹이 고른 항목 · + 복사본 `{구조물 id}__{그룹 id}`. 정의된 그룹만(미지정 그룹 = 「미지정」 알림에 셈) · 정의가 사라진 + 그룹 연결은 기록에만 남고 표에 안 들어감.""" + from common_util.common_util_summary_item import GROUP_JOIN + from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of + + defined = groups_of() + out: list[dict[str, Any]] = [] + for item in items: + links = item.pop("group_items", None) or {} + out.append(item) + for group in defined.get(item["type_id"], []): + out.append( + { + **item, + "summary_item": links.get(group["id"]), + "복사본": f"{item['복사본']}{GROUP_JOIN}{group['id']}", + "그룹": group["name"], + } + ) + return out # ── 값 · 글 ─────────────────────────────────────────── diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 0dcec025b..2a3c67f3e 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -35,7 +35,7 @@ from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex -from common_util.common_util_summary_item import parse_summary_item +from common_util.common_util_summary_item import parse_group_items, parse_summary_item from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME, @@ -92,6 +92,8 @@ class PipePoint: y: float | None = None # 구조물 집계표 항목(PLAN 21-1 · options 밖) — 없으면 None · 모양은 `common_util_summary_item` summary_item: dict[str, Any] | None = None + # 옵션 그룹(M02 PLAN 46)마다 고른 집계 항목 `{그룹 id: summary_item}` — 본체는 `summary_item` + group_items: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. @@ -115,6 +117,8 @@ class PipePoint: payload["y"] = round(float(self.y), 3) if self.summary_item: payload["summary_item"] = self.summary_item + if self.group_items: + payload["group_items"] = self.group_items return payload @@ -355,6 +359,7 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: else None ), summary_item=parse_summary_item(item.get("summary_item")), + group_items=parse_group_items(item.get("group_items")), ) ) points.sort(key=lambda point: point.chainage_m) @@ -406,6 +411,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) point.end_m = ref.end_m point.options = ref.options point.summary_item = ref.summary_item + point.group_items = ref.group_items return base diff --git a/common_util/common_util_summary_item.py b/common_util/common_util_summary_item.py index dee3682ea..542681d3c 100644 --- a/common_util/common_util_summary_item.py +++ b/common_util/common_util_summary_item.py @@ -62,6 +62,29 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None: return {"layer": layer, "owner": owner, "key": key, "name": name} +#: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙) +_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") +#: 그룹 복사본 이름 잇개 — `{구조물 id}__{그룹 id}`(구조물 id 는 hex · 관은 `pipe@측점`) +GROUP_JOIN = "__" + + +def parse_group_items(value: Any) -> dict[str, dict[str, Any]] | None: + """그룹별 집계 항목 `{그룹 id: summary_item}`(PLAN 46-3) — 빈 값 · 빈 묶음은 None · + null 항목은 뺌 · 그룹 id · 항목 모양이 틀리면 ValueError.""" + if value is None: + return None + if not isinstance(value, dict): + raise ValueError("그룹 집계 항목(group_items)은 묶음이어야 합니다.") + out: dict[str, dict[str, Any]] = {} + for gid, item in value.items(): + if not isinstance(gid, str) or not _GROUP_ID.match(gid): + raise ValueError(f"그룹 id 모양이 아닙니다: {gid}") + parsed = parse_summary_item(item) + if parsed: + out[gid] = parsed + return out or None + + def _key_ok(layer: str, owner: Any, key: str) -> bool: """옛 · 마스터 SS 키는 어느 층이든 · SC · SP 는 그 층에서 머리 번호가 owner 와 같을 때만.""" if _KEY.match(key): diff --git a/common_util/common_util_summary_item.ts b/common_util/common_util_summary_item.ts index 9e1f87da1..b6bb9fe80 100644 --- a/common_util/common_util_summary_item.ts +++ b/common_util/common_util_summary_item.ts @@ -45,6 +45,41 @@ export function readSummaryItem(value: unknown): SummaryItem | null { return isSummaryItem(value) ? value : null; } +/** 옵션 그룹(M02 PLAN 46)마다 고른 항목 `{그룹 id: 참조}` — 본체는 `summary_item`. */ +export type GroupItems = Record; + +/** 그룹 복사본 id 잇개 — `{구조물 id}__{그룹 id}`(서버 `GROUP_JOIN` 과 짝). */ +export const GROUP_JOIN = "__"; + +/** 서버 · 세션 값을 그룹 참조 묶음으로 — 모양이 틀린 항목은 뺌 · 없으면 빈 묶음. */ +export function readGroupItems(value: unknown): GroupItems { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as Record).filter( + (entry): entry is [string, SummaryItem] => + /^g[0-9a-z]{1,16}$/.test(entry[0]) && isSummaryItem(entry[1]), + ), + ); +} + +/** 저장 · 이음에 실을 연결 칸 — 지정한 것만(본체 `summary_item` · 그룹 `group_items`). */ +export function linkFields(source: { summary_item?: unknown; group_items?: unknown }): { + summary_item?: SummaryItem; + group_items?: GroupItems; +} { + const summary = readSummaryItem(source.summary_item); + const groups = readGroupItems(source.group_items); + return { + ...(summary ? { summary_item: summary } : {}), + ...(Object.keys(groups).length ? { group_items: groups } : {}), + }; +} + +/** 연결이 하나라도 있나 — 본체 또는 그룹. */ +export function hasLinks(source: { summary_item?: unknown; group_items?: unknown }): boolean { + return Object.keys(linkFields(source)).length > 0; +} + /** 표시 글 — 이름(없으면 「미지정」). 키는 앞에 내세우지 않는다(PLAN 21-0). */ export function summaryLabel(item: SummaryItem | null): string { return item ? item.name || item.key : "미지정"; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 76e35638c..f5e9f6ebb 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -355,6 +355,8 @@ async function saveWith({ setTempPipeChainage() {}, summary: () => summaryValue, setSummary: (value) => (summaryValue = value), + groupItems: () => ({}), // 옵션 그룹(PLAN 46-3) — 이 시험은 그룹 없음 + setGroupItems() {}, }; if (summaryOnly) { const done = commitModule.commitSummaryOnly(ctx); diff --git a/resources/tester/test_b05_facility_carry.py b/resources/tester/test_b05_facility_carry.py index a70c3a43c..221f12924 100644 --- a/resources/tester/test_b05_facility_carry.py +++ b/resources/tester/test_b05_facility_carry.py @@ -85,8 +85,11 @@ def test_화면과_서버가_같은_답(): def test_화면_보관소_끌기_되돌리기_때_속성이_따라감(): """BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만.""" body = FACILITY.read_text(encoding="utf-8") + # 연결 칸 도우미(`linkFields` · `hasLinks` · PLAN 46-3) — import 없는 파일이라 통째로 붙임 + links = (ROOT / "common_util" / "common_util_summary_item.ts").read_text(encoding="utf-8") script = ( - "type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n" + links + + "\ntype PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n" "interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;" " options?: Record }\n" + "\n".join( diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 457227f58..8dbe89ae8 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -24,6 +24,7 @@ def out(): ["node", os.path.join(HERE, "helper_b05_structures_form_summary.cjs")], capture_output=True, text=True, + encoding="utf-8", # 한글 출력 — Windows 기본(cp949)으로 읽으면 깨짐 timeout=60, ) assert proc.returncode == 0, proc.stderr diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index a034a6b7e..83ee822ee 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -225,7 +225,13 @@ def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리(): def test_B05_구조물_폼이_구조물_id_를_모달에_넘김(): + # 배선은 `_Structures_Groups.bindSummaryPickers`(PLAN 46-3) — 그룹은 `{id}__{그룹 id}` + picks = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Groups.ts").read_text( + encoding="utf-8" + ) + assert "opts.editingId() ?? (pipe !== null ? pipeStructureId(pipe) : null)" in picks + assert "`${id}${GROUP_JOIN}${groupId}`" in picks panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( encoding="utf-8" ) - assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel + assert "bindSummaryPickers({" in panel diff --git a/resources/tester/test_structure_group_items.py b/resources/tester/test_structure_group_items.py new file mode 100644 index 000000000..85b98ac2c --- /dev/null +++ b/resources/tester/test_structure_group_items.py @@ -0,0 +1,97 @@ +"""옵션 그룹별 집계 항목 `group_items` (PLAN 46-3 · 46-4). + +못박는 것 + - 칸 모양: `{그룹 id: summary_item}` · null 항목 뺌 · 빈 묶음 = None · 그룹 id · 항목이 틀리면 거절. + - 구조물 기록 · 관 지점 기록 둘 다 칸을 읽고 씀(옛 파일 = 칸 없음). + - [저장] 뒤 복사본: 그룹 연결마다 `{구조물 id}__{그룹 id}` 폴더(본체와 따로). + - 집계표 채움: 정의된 그룹마다 한 건 더(그 그룹이 고른 열) · 미지정 그룹은 「미지정」 알림에 셈 · + 정의가 사라진 그룹 연결은 표에 안 들어감(기록 · 복사본은 남음). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_summary_item import parse_group_items +from M02_MasterTemplete import M02_Item_Snapshot as snap +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.StructOptions import M02_SO_Groups as groups +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + PIPE, + RV, + _dry, + _items, + _pipe, + _save, + world, +) + + +def _define(system: Path, monkeypatch, doc: dict) -> None: + monkeypatch.setattr(store, "FOLDER", system) + (system / groups.FILE).write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +def test_칸_모양(): + assert parse_group_items(None) is None + assert parse_group_items({}) is None + assert parse_group_items({"g1": None}) is None + assert parse_group_items({"g1": MS, "g2": None}) == {"g1": MS} + for bad in ({"G1": MS}, {"g1": {"layer": "x"}}, ["g1"], {"g_1": MS}): + with pytest.raises(ValueError): + parse_group_items(bad) + + +def test_구조물_관_기록이_칸을_읽고_씀(): + item = StructureInstance( + type_id="x", placement="point", chainage_m=1, group_items={"g1": MS, "g2": None} + ) + assert item.group_items == {"g1": MS} + assert StructureInstance(type_id="x", placement="point", chainage_m=1).group_items is None + point = parse_pipe_points([{"chainage_m": 10.0, "source": "user", "group_items": {"g9": RV}}]) + assert point[0].group_items == {"g9": RV} + assert point[0].as_dict()["group_items"] == {"g9": RV} + plain = parse_pipe_points([{"chainage_m": 10.0, "source": "user"}])[0] + assert "group_items" not in plain.as_dict() + + +def test_그룹_연결마다_복사본_폴더가_따로(world): + root = world["root"] + wall = {**_dry(MS), "group_items": {"g1": RV}} + pipe = {**_pipe(PIPE), "group_items": {"gin": MS}} + result = _save(root, [wall], [pipe]) + assert result["만듦"] == ["pipe@40.0", "pipe@40.0__gin", "st0", "st0__g1"] + assert snap.FOLDER_PATTERN.match("pipe@40.0__gin") + manifest = json.loads((_items(root) / "st0__g1/manifest.json").read_text("utf-8")) + assert manifest["원본"]["key"] == RV["key"] and manifest["구조물"] == ["st0__g1"] + # 그룹 연결을 풀면 그 복사본만 지움 + again = _save(root, [_dry(MS)], [pipe]) + assert again["지움"] == ["st0__g1"] and again["만듦"] == [] + + +def test_채움은_정의된_그룹마다_한_건_더(world, monkeypatch): + """메쌓기 벽 = 본체(돌쌓기 ms) + 그룹 「기슭막이」(rv) — 한 구조물이 두 열에 · 길이 10.""" + root, system = world["root"], world["system"] + _define(system, monkeypatch, {"masonry_dry": [{"id": "g1", "이름": "기슭막이", "옵션": []}]}) + linked = {**_dry(MS), "group_items": {"g1": RV, "gold": PIPE}} # gold = 정의 없음 + bare = {**_dry(None), "chainage_m": 125, "start_m": 120, "end_m": 130} + _save(root, [linked, bare], []) + table = json.loads((system / "table/구조물집계표.json").read_text("utf-8")) + doc = fill.fill_table(root, table) + values = {row["id"]: row["값"] for row in doc["줄"] if not row.get("고정")} + row = {key.split("|")[0]: value for key, value in values["s80.00~90.00"].items()} + assert row["ms"] == 10 # 본체 = 돌쌓기 + assert row["rv"] == 10 # 그룹 = 기슭막이(같은 벽 길이) + assert set(values) == {"s80.00~90.00"} # 미지정 벽은 줄도 없음 + # 둘째 벽: 본체 미지정 + 그룹 미지정 = 2건 · 정의 없는 gold 는 셈 밖 + assert "집계 항목 미지정 2건 — 표에 안 들어감" in doc["알림"] + items = fill.collect_items(root) + assert [i["복사본"] for i in items] == ["st0", "st0__g1", "st1", "st1__g1"] + assert items[1]["그룹"] == "기슭막이" and items[1]["summary_item"] == RV diff --git a/resources/tester/test_summary_clear_and_tree_dialog.py b/resources/tester/test_summary_clear_and_tree_dialog.py index 0a27fc23d..58e6cd1fe 100644 --- a/resources/tester/test_summary_clear_and_tree_dialog.py +++ b/resources/tester/test_summary_clear_and_tree_dialog.py @@ -48,5 +48,6 @@ def test_관_시설_보관소는_응답을_정본으로_집계_항목이_없으 """횡단 보기에서 [해제]한 관 초안이 응답으로 돌아올 때 옛 지정이 되살아나면 안 됨.""" store = _text("B05_Profile/B05_Profile_UI_Drainage_Facility.ts") body = store.split("replaceFromResponse(")[1].split("},\n /** 한 지점의 시설을 바꾼다")[0] - assert "const summary = point.summary_item ?? null;" in body + # 응답 칸만(본체 · 옵션 그룹 · PLAN 46-3) — 없으면 미지정 + assert "...linkFields(point)," in body assert "before.find" not in body # 화면이 쥔 값을 이어받던 옛 규칙