Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2

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