feat(B08): 미연결 구조물 바로 연결 — 고르기 모달 → 그 칸만 저장 → 다시 셈(154-2)
- 새 서버 길 PUT /api/projects/{id}/items/{사본 id}/link — 그 시설 조합 칸 하나만 정본에
(관 지점 = pipe_points.json 노선 지문 그대로 · 박스/관 facility 로 가름 · 구조물 = 판번호 +1) → safe_sync
- 구조물도 모달 맨 위 「조합 연결」 칸(새 B08_Quantity_UI_Link) — 칸마다 연결 여부 · [선택]
- B05/B06 세션 초안(구조물 · 관) 있으면 막고 「B05 · B06 [저장] 먼저」 알림 · 저장 뒤 탭 몸통 새로 그림
- 공용 창구 projectItems().link 추가 · 새 시험 test_154_2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user