feat(b08): 조합 연결 [연결 끊기] · 단추 모달 [닫기] 양식(156-1 · 156-3)
- 모달 「조합 연결」 칸 = 그 측점 모든 시설 칸(집계 응답 facilities) · 연결된 칸만 [연결 끊기] - 끊기 = 확인 창 → link 길에 null(그 칸만 비움 · 판 +1 · 노선 지문 그대로) · 사본 맞춤 안 부름(사본 폴더 그대로) - B05/B06 초안이면 막고 알림 · 끊은 뒤 다시 셈(「· 미연결」) - [선택] · [연결 끊기] = ghost 기본 크기(모달 [닫기] 같은 양식) - 시험 test_156_1_b08_unlink Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
`pipes` = 관마다 길이(커플링 열 · 측점) — 좌측 패널 1본 길이를 바꾸면 화면이 같은 식으로 바로 다시 셈
|
||||
(짝 = `couplings` ↔ `B08_Quantity_Options_Calc.ts` `couplings` · `recountCouplings` · PLAN 122).
|
||||
`unlinked` = 셈한 칸에 사본 없는 시설 — 칸 전부 + 연결 여부(줄 「미연결」 · 바로 연결 · PLAN 154).
|
||||
`facilities` = 같은 꼴로 모든 시설(모달 「조합 연결」 칸 · [연결 끊기] · PLAN 156-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -605,6 +606,9 @@ def pipe_summary(
|
||||
notes.append(f"세월교 관 길이 없음 {no_ford}곳 — B06 횡단 [저장] 뒤 채워짐")
|
||||
if one_m is None:
|
||||
notes.append(f"1본 길이 변수({ONE_LENGTH_VAR}) 빈칸 — 커플링 빈칸")
|
||||
names_of = {t["type_id"]: t["name"] for t in kinds.get("types") or []}
|
||||
stored = {key: point.group_items or {} for key, point in raw.items()}
|
||||
every = {(item["type_id"], item["복사본"].partition(GROUP_JOIN)[0]) for item in items}
|
||||
return {
|
||||
**out,
|
||||
"notes": notes,
|
||||
@@ -612,13 +616,10 @@ 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()},
|
||||
),
|
||||
# 154-1 · 줄 누름 → 바로 연결(154-2)
|
||||
"unlinked": unlinked_facilities(items, linked, missing, names_of, stored),
|
||||
# 모든 시설 칸(모달 「조합 연결」 · 연결된 칸 [연결 끊기] · 156-1)
|
||||
"facilities": unlinked_facilities(items, linked, every, names_of, stored),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
padding-left: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b08-link__buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b08-link__state {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 원천 = 그 구조물의 프로젝트 사본만(연결된 사본 · PLAN 143-1) — 없으면 「사본 없음」 · 읽기만
|
||||
* 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`)
|
||||
* 미연결 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2) — 저장하면 닫고 다시 셈
|
||||
* 시설이 있으면 맨 위 「조합 연결」 칸(`B08_Quantity_UI_Link` · PLAN 154-2 · 156-1) — 저장 · 끊기면 닫고 다시 셈
|
||||
* 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -44,9 +44,9 @@ export interface SheetsRequest {
|
||||
/** 연결된 사본 `{구조물 id: 사본}`(집계 응답 · 143-1) */
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
describe: (item: SummaryItem) => ComboLabel | null;
|
||||
/** 그 측점 미연결 시설(154) */
|
||||
unlinked: UnlinkedFacility[];
|
||||
/** 바로 연결을 저장한 뒤 — 집계표 다시 셈 */
|
||||
/** 그 측점 시설 칸 전부(154 · 156-1) */
|
||||
facilities: UnlinkedFacility[];
|
||||
/** 바로 연결 · 끊기를 저장한 뒤 — 집계표 다시 셈 */
|
||||
onLinked: () => void;
|
||||
}
|
||||
|
||||
@@ -120,9 +120,9 @@ export function openStructureSheets(request: SheetsRequest): void {
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
const linking = request.unlinked.length
|
||||
const linking = request.facilities.length
|
||||
? [
|
||||
linkSection(request.projectId, request.unlinked, () => {
|
||||
linkSection(request.projectId, request.facilities, () => {
|
||||
closed = true;
|
||||
handles.forEach((cards) => cards.destroy());
|
||||
close();
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_UI_Link.ts
|
||||
* 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2) — 그 측점 미연결 시설의 칸 전부 + 연결 여부.
|
||||
* 구조물도 모달 맨 위 「조합 연결」 칸(PLAN 154-2 · 156) — 그 측점 시설의 칸 전부 + 연결 여부.
|
||||
*
|
||||
* [선택] → 공용 고르기 모달(`openItemPicker` · 누를 때 `import()`) → 바로 저장
|
||||
* = PUT …/items/{사본 id}/link(그 칸 하나만 정본에 · 서버가 사본 맞춤) → 집계표 다시 셈
|
||||
* [연결 끊기](연결된 칸만) → 확인 창 → 같은 길에 null(그 칸만 비움 · 사본 폴더 그대로 · 156-1)
|
||||
* 단추 = 모달 머리 [닫기]와 같은 양식(ghost · 기본 크기 · 156-3)
|
||||
* B05/B06 세션 초안(구조물 · 관)이 있으면 막고 알림 — 그쪽 [저장]이 목록 통째로 덮어 연결이 사라짐
|
||||
* ========================================================================== */
|
||||
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, showConfirmDialog, 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";
|
||||
@@ -54,6 +56,24 @@ async function pickAndLink(
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸 하나 연결 끊기 — 끊었으면 true(취소 · 막힘 · 실패 = false · 156-1). */
|
||||
async function unlink(projectId: string, facility: UnlinkedFacility, slot: UnlinkedSlot) {
|
||||
if (hasPendingDesign(projectId)) {
|
||||
showToast(L("B08_Quantity_Link_Pending"), "warning", 5000);
|
||||
return false;
|
||||
}
|
||||
if (!(await showConfirmDialog(L("B08_Quantity_Link_UnlinkAsk"), L("B08_Quantity_Link_Unlink"))))
|
||||
return false;
|
||||
try {
|
||||
await projectItems(projectId).link(slot.copy, facility.facility, null);
|
||||
showToast(L("B08_Quantity_Link_Unlinked"), "success");
|
||||
return true;
|
||||
} catch (error) {
|
||||
showToast(`${L("B08_Quantity_Link_UnlinkFailed")} — ${String(error)}`, "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 「조합 연결」 칸 — 시설마다 이름 + 칸 줄(이름 · 연결 여부 · [선택]). `onLinked` = 저장 뒤(모달 닫고 다시 셈). */
|
||||
export function linkSection(
|
||||
projectId: string,
|
||||
@@ -83,18 +103,26 @@ export function linkSection(
|
||||
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);
|
||||
const buttons = document.createElement("span");
|
||||
buttons.className = "b08-link__buttons";
|
||||
const act = (label: string, run: typeof pickAndLink): HTMLButtonElement => {
|
||||
const button = createButton({
|
||||
label,
|
||||
variant: "ghost", // 모달 [닫기]와 같은 양식(156-3)
|
||||
onClick: () => {
|
||||
buttons.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
||||
void run(projectId, facility, slot).then((saved) =>
|
||||
saved
|
||||
? onLinked()
|
||||
: buttons.querySelectorAll("button").forEach((b) => (b.disabled = false)),
|
||||
);
|
||||
},
|
||||
});
|
||||
return button;
|
||||
};
|
||||
buttons.append(act(L("B08_Quantity_Link_Pick"), pickAndLink));
|
||||
if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink));
|
||||
row.append(name, state, buttons);
|
||||
section.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ interface PipeSummary {
|
||||
linked: Record<string, LinkedSnapshot>;
|
||||
/** 미연결 시설(154-1) */
|
||||
unlinked: UnlinkedFacility[];
|
||||
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
|
||||
facilities: UnlinkedFacility[];
|
||||
}
|
||||
|
||||
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
|
||||
@@ -247,7 +249,7 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin
|
||||
combos: combosAt(things, station),
|
||||
linked,
|
||||
describe,
|
||||
unlinked: unlinkedAt(unlinked, station),
|
||||
facilities: unlinkedAt(saved?.facilities ?? [], station),
|
||||
// 새 몸통에 다시 그림 — 옛 몸통은 떨어져 옵션 듣기가 스스로 빠짐(겹침 없음)
|
||||
onLinked: () => {
|
||||
const fresh = document.createElement("div");
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림)
|
||||
POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감)
|
||||
PUT …/items/{사본 id}/link {summary_item | null, facility} B08 바로 연결(154-2) — 그 시설
|
||||
조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed}
|
||||
조합 칸 하나만 정본에 씀 → 사본 맞춤 → {폴더, snapshot_failed} · null = 끊기(156-1 ·
|
||||
맞춤 안 함 · 사본 폴더 그대로)
|
||||
POST …/items/{폴더}/calc 복사본만으로 계산 — 층 셈 한 벌(142-1 · 줄 · 자재 ·
|
||||
기준합 · 반영합 + 알약 · 빠진입력 · 빈 입력 = 자동값)
|
||||
종류 = 도면 · 면적 · 입력값 · 고른자재 · 일위대가(읽기만 · 쓰면 400) ·
|
||||
@@ -164,6 +165,8 @@ async def link_item(
|
||||
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)
|
||||
if item is None: # 끊기(156-1) — 맞춤은 고아 사본을 지우므로 안 부름 · 사본 폴더 그대로
|
||||
return {"폴더": folder_name(copy), "snapshot_failed": []}
|
||||
failed = await asyncio.to_thread(safe_sync, root, importer(session))
|
||||
return {"폴더": folder_name(copy), "snapshot_failed": failed}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B08 조합 [연결 끊기](PLAN 156-1 · 156-3) — 임시 저장소(154-2 시험 바탕 그대로).
|
||||
|
||||
- 링크 길에 null = 그 시설 칸만 비움 · 다른 시설 · 칸 · 관 지점 노선 지문 그대로 · 구조물 판 +1
|
||||
- 끊기는 사본 맞춤을 안 부름 — 사본 폴더 그대로(고아 정리 안 함)
|
||||
- 집계 응답 `facilities` = 모든 시설 칸(연결된 칸 포함) · 끊은 시설은 `unlinked` 로
|
||||
- 화면 = 연결된 칸만 [연결 끊기] · 확인 창 · 초안이면 막음 · 단추 = 모달 [닫기] 양식
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_Project_Items_Router as items_router
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
from resources.tester.test_154_2_b08_direct_link import ( # noqa: F401 — 시험 저장소
|
||||
MS,
|
||||
PIPE,
|
||||
ROOT,
|
||||
_points,
|
||||
_structures,
|
||||
root,
|
||||
)
|
||||
|
||||
|
||||
def _folders(root: Path) -> set[str]:
|
||||
return {p.name for p in snap.structures_dir(root).iterdir() if p.is_dir()}
|
||||
|
||||
|
||||
def _unlink(monkeypatch, root: Path, copy: str, facility: str | None) -> dict:
|
||||
async def fake_root(session, project_id):
|
||||
return root
|
||||
|
||||
def no_sync(*args):
|
||||
raise AssertionError("끊기는 사본 맞춤을 부르지 않음")
|
||||
|
||||
monkeypatch.setattr(items_router, "_root", fake_root)
|
||||
monkeypatch.setattr(items_router, "safe_sync", no_sync)
|
||||
body = items_router.LinkBody(summary_item=None, facility=facility)
|
||||
return asyncio.run(items_router.link_item("proj", copy, body, session={}))
|
||||
|
||||
|
||||
def test_구조물_끊기_그_칸만_사본_그대로(root, monkeypatch):
|
||||
folders, points = _folders(root), _points(root)
|
||||
assert snap.folder_name("st1") in folders
|
||||
reply = _unlink(monkeypatch, root, "st1", None)
|
||||
assert reply == {"폴더": snap.folder_name("st1"), "snapshot_failed": []}
|
||||
got = _structures(root)
|
||||
assert got["revision"] == 5
|
||||
st0, st1 = got["structures"]
|
||||
assert st1.get("summary_item") is None and st1["chainage_m"] == 400
|
||||
assert st0.get("summary_item") is None and st0["chainage_m"] == 300
|
||||
assert _points(root) == points # 관 지점 · 노선 지문 그대로
|
||||
assert _folders(root) == folders # 사본 폴더 그대로
|
||||
assert "st1" not in snap.linked_snapshots(root)
|
||||
|
||||
|
||||
def test_관_그룹_끊기_다른_시설_그대로(root, monkeypatch):
|
||||
folders, before = _folders(root), _points(root)
|
||||
_unlink(monkeypatch, root, "pipe@100.0__grv6lny", "pipe")
|
||||
after = _points(root)
|
||||
assert after["route_signature"] == "sig-1"
|
||||
assert "group_items" not in after["points"][0]
|
||||
assert after["points"][1:] == before["points"][1:] # 같은 측점 박스 · 다른 관 그대로
|
||||
assert _structures(root)["structures"][1]["summary_item"] == MS
|
||||
assert _folders(root) == folders
|
||||
|
||||
|
||||
def test_집계_모든_시설_칸_끊으면_미연결(root, monkeypatch):
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
designs = [{"chainage_m": 100.0, "design": {"pipe_length_m": 12}}]
|
||||
first = summary.pipe_summary(root, designs, names, 6.0)
|
||||
assert not any(u["id"] == "st1" for u in first["unlinked"])
|
||||
st1 = next(f for f in first["facilities"] if f["id"] == "st1")
|
||||
assert st1["slots"][0] == {"copy": "st1", "group": None, "item": MS, "linked": True}
|
||||
pipe = next(
|
||||
f for f in first["facilities"] if f["id"] == "pipe@100.0" and f["type_id"] == "pipe"
|
||||
)
|
||||
assert any(s["copy"] == "pipe@100.0__grv6lny" and s["linked"] for s in pipe["slots"])
|
||||
assert {u["id"] for u in first["unlinked"]} <= {f["id"] for f in first["facilities"]}
|
||||
_unlink(monkeypatch, root, "st1", None)
|
||||
after = summary.pipe_summary(root, designs, names, 6.0)
|
||||
st1 = next(u for u in after["unlinked"] if u["id"] == "st1")
|
||||
assert st1["slots"][0]["linked"] is False and st1["chainage_m"] == 400
|
||||
assert PIPE == _points(root)["points"][0]["group_items"]["grv6lny"] # 다른 칸 그대로
|
||||
|
||||
|
||||
def test_화면_연결된_칸만_끊기_닫기_양식():
|
||||
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8")
|
||||
assert 'if (slot.linked) buttons.append(act(L("B08_Quantity_Link_Unlink"), unlink))' in ui
|
||||
assert ".link(slot.copy, facility.facility, null)" in ui
|
||||
assert 'showConfirmDialog(L("B08_Quantity_Link_UnlinkAsk")' in ui
|
||||
assert ui.count("hasPendingDesign(projectId)") == 2 # 선택 · 끊기 둘 다 막음
|
||||
assert 'size: "sm"' not in ui # 모달 [닫기] = ghost · 기본 크기
|
||||
modal = (ROOT / "ui_template/ui_template_modal.ts").read_text(encoding="utf-8")
|
||||
assert 'label: opts.closeLabel,\n variant: "ghost",\n onClick' in modal
|
||||
page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
|
||||
assert "facilities: unlinkedAt(saved?.facilities ?? [], station)" in page
|
||||
sheets = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
|
||||
assert "linkSection(request.projectId, request.facilities" in sheets
|
||||
@@ -69,6 +69,13 @@ export const ui_locales_b4 = {
|
||||
"연결은 저장했으나 사본을 못 만듦",
|
||||
"Linked, but the project copy could not be made",
|
||||
],
|
||||
B08_Quantity_Link_Unlink: ["연결 끊기", "Unlink"],
|
||||
B08_Quantity_Link_UnlinkAsk: [
|
||||
"이 칸의 조합 연결을 끊습니다 — 프로젝트 사본은 남습니다.",
|
||||
"Unlink this slot — the project copy is kept.",
|
||||
],
|
||||
B08_Quantity_Link_Unlinked: ["조합 연결을 끊었습니다.", "Combination unlinked."],
|
||||
B08_Quantity_Link_UnlinkFailed: ["연결을 끊지 못했습니다", "Failed to unlink"],
|
||||
B08_Quantity_Link_Pending: [
|
||||
"B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.",
|
||||
"Unsaved edits in B05 · B06 — save there first, then link.",
|
||||
|
||||
Reference in New Issue
Block a user