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