Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_6
This commit is contained in:
@@ -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<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1 · options 밖 칸) — 없으면 null. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
/** 이 구조물이 뜻하는 구조물 집계표 항목(21-1) — 없으면 null(미지정 · 집계표에 안 들어감). */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
interface StructureTypesResponse {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(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<string, string | number>;
|
||||
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) };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,6 +84,8 @@ interface MergeableAttributes {
|
||||
options?: Record<string, string | number>;
|
||||
/** 집계 항목(options 밖) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
|
||||
summary_item?: unknown;
|
||||
/** 옵션 그룹 집계 항목(PLAN 46-3) — 다음 값에 칸이 없으면(undefined) 옛 지정을 그대로 잇는다. */
|
||||
group_items?: unknown;
|
||||
}
|
||||
|
||||
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
|
||||
@@ -99,5 +101,14 @@ export function mergeFacilityOptions<T extends MergeableAttributes>(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
|
||||
group_items?: GroupItems | null;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
/** 옵션 그룹마다 고른 집계 항목(PLAN 46-3). */
|
||||
group_items?: GroupItems | null;
|
||||
}>,
|
||||
) => void;
|
||||
/** 테이블에서 구조물 라인을 끌어 옮김. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, SummaryRow>();
|
||||
/** 옮긴 칸 · 제자리 표지 */
|
||||
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<string>): 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<HTMLElement>("[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();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<string, string | number> = {}): 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();
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ── 값 · 글 ───────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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<string, SummaryItem>;
|
||||
|
||||
/** 그룹 복사본 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<string, unknown>).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 : "미지정";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string | number> }\n"
|
||||
+ "\n".join(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 # 화면이 쥔 값을 이어받던 옛 규칙
|
||||
|
||||
Reference in New Issue
Block a user