From 90e478fe0bcb5cb0c507024432816575daeab0ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 13:31:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(M02):=20=EC=9E=90=EC=9E=AC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9D=B4=EB=A6=84=20=EC=98=A4=EB=A5=98=20=EA=B8=80?= =?UTF-8?q?=20=E2=80=94=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=C2=B7=20=ED=82=A4?= =?UTF-8?q?=20=EC=97=86=EC=9D=B4=20=EC=89=AC=EC=9A=B4=20=EB=A7=90=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20(PLAN=2021-10=20=ED=9D=A0=207=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 이름 · 60자 넘는 이름을 틀 검사 앞에서 「이름」 한 줄로 막음 - 오류 글에 이름은 30자에서 자름(…) - 시험: 겹침 글 · 긴 이름 자름 · 60자 넘음 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- M02_MasterTemplete/M02_MasterTemplete_Matset.py | 16 ++++++++++++++++ resources/tester/test_m02_matset.py | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.py b/M02_MasterTemplete/M02_MasterTemplete_Matset.py index df87e2fc..09788153 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.py @@ -91,6 +91,20 @@ def _clean(row: Any, key: str, keep: dict | None = None) -> dict[str, Any]: } +def _short(name: str, at: int = 30) -> str: + return name if len(name) <= at else name[:at] + "…" + + +def _check_name(rows: list, key: str, name: str) -> None: + """이름 겹침 · 긴 이름 — 틀 검사(파일명 · 키가 섞인 글) 앞에서 쉬운 말 한 줄로 막음.""" + if len(name) > mmc.MAX_이름: + raise store.StoreError(422, {"errors": [f"이름이 너무 김 — {mmc.MAX_이름}자까지"]}) + if any(r.get("키") != key and str(r.get("이름", "")).strip() == name for r in rows): + raise store.StoreError( + 422, {"errors": [f"「{_short(name)}」 이름의 자재 조합이 이미 있음 — 다른 이름으로"]} + ) + + def _save(doc: dict, version: str) -> str: """틀 검사 → 판이 같을 때만 씀 → 새 판.""" if errors := mmc.check_form(FILE, doc): @@ -111,6 +125,7 @@ def create(row: Any, version: str | None = None) -> dict[str, Any]: fixed = _clean(row, key) if fixed["본뜬키"] and fixed["본뜬키"] not in {r.get("키") for r in doc["줄"]}: raise store.StoreError(400, f"본뜬키 「{fixed['본뜬키']}」 — 있는 다른 자재 조합 키여야 함") + _check_name(doc["줄"], key, fixed["이름"]) doc = {**doc, "다음": int(doc.get("다음") or 1) + 1, "줄": [*doc["줄"], fixed]} return {"키": key, "판": _save(doc, have if version is None else version), "줄": fixed} @@ -118,6 +133,7 @@ def create(row: Any, version: str | None = None) -> dict[str, Any]: def edit(key: str, row: Any, version: str) -> dict[str, Any]: doc, _ = _load() fixed = _clean(row, key, keep=_find(doc, key)) + _check_name(doc["줄"], key, fixed["이름"]) doc = {**doc, "줄": [fixed if r.get("키") == key else r for r in doc["줄"]]} return {"키": key, "판": _save(doc, version), "줄": fixed} diff --git a/resources/tester/test_m02_matset.py b/resources/tester/test_m02_matset.py index 7c4589c0..c38addf7 100644 --- a/resources/tester/test_m02_matset.py +++ b/resources/tester/test_m02_matset.py @@ -83,7 +83,15 @@ def test_없는_자재_두_번_이름_본뜬키는_막는다(client) -> None: ok = _new(client, "원본", [M1]).json()["키"] copy = _new(client, "본뜬 것", [M1, M2], 본뜬키=ok).json() assert copy["줄"]["본뜬키"] == ok and copy["키"] != ok - assert _new(client, "원본", [M2]).status_code == 422 # 이름 겹침 + dup = _new(client, "원본", [M2]) # 이름 겹침 — 파일명 · 키 없이 쉬운 말 한 줄 + assert dup.status_code == 422 and dup.json()["detail"]["errors"] == [ + "「원본」 이름의 자재 조합이 이미 있음 — 다른 이름으로" + ] + long = "긴" * 40 + assert _new(client, long, [M1]).status_code == 200 + cut = _new(client, long, [M2]).json()["detail"]["errors"][0] + assert cut.startswith("「" + "긴" * 30 + "…」") and matset.FILE not in cut and "UM0" not in cut + assert _new(client, "긴" * 61, [M1]).json()["detail"]["errors"] == ["이름이 너무 김 — 60자까지"] assert _new(client, "낡은 판", [M2], 판="0000").status_code == 409 From 22f3df9476bdf762449c15c9d0fcb764b72aff72 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 13:36:35 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(M02):=20=EB=B9=88=20=EC=B8=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B2=AB=20=EC=97=B4=20=EB=A7=8C=EB=93=A4=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=9D=BD=EA=B8=B0=EB=A7=8C=20=ED=91=9C=EC=9D=98=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EB=8B=A8=EC=B6=94=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20(PLAN=2021-10=20=ED=9D=A0=201=20=C2=B7=204?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 부품 — 열 0 이면 [열 더하기] 가 켜져 첫 열을 만들고 머리 글 고치기를 바로 엶(종류/분류 는 / 로 이음) - 표 부품 `readOnly` — 단추 줄 · 안내 글 숨김 · 칸 · 머리 · 층 이름 고치기 · 열 너비 끌기 · Delete 막음 · M02 가 읽기만일 때 넘김 - 시험 test_m02_layer_screen.py 7(첫 열 Node 실행 · 읽기만 자리) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- .../M02_MasterTemplete_UI_Main.ts | 1 + resources/tester/test_m02_layer_screen.py | 40 ++++++++++++++++++ ui_template/sheet/ui_template_sheet.css | 5 +++ ui_template/sheet/ui_template_sheet.ts | 41 +++++++++++++++---- ui_template/sheet/ui_template_sheet_ops.ts | 12 ++++++ 5 files changed, 90 insertions(+), 9 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a7a83fb7..76bc23f3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -354,6 +354,7 @@ export function createMain( const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? []; editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master", + readOnly, onChange: () => (dirty = !readOnly), ...(struct ? { diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 1e628924..febeedee 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -109,3 +109,43 @@ def test_읽기만_회사용_개인용으로_복사_막대(): assert "titleOf(layer)" in page layer = _text(LAYER) assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer + + +SHEET = ROOT / "ui_template" / "sheet" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_열_0_첫_열_만들기(): + """빈 개인 · 회사 층 — 열이 하나도 없어도 첫 열(머리 글 + 빈 아래 층)을 만듦.""" + ops = _text(SHEET / "ui_template_sheet_ops.ts") + fresh = re.search(r"^function freshId[\s\S]*?^}", ops, re.M) + first = re.search(r"^export function addFirstColumn[\s\S]*?^}", ops, re.M) + assert fresh and first, "freshId · addFirstColumn 을 못 찾음" + script = ( + "interface SheetColumn { id: string; 머리: (string | null)[]; 단위?: string | null;" + " 꼴?: string; 일위대가?: string | null; 자재조합?: string | null }\n" + "interface SheetDoc { 열: SheetColumn[] }\n" + + fresh.group(0) + + "\n" + + first.group(0).replace("export ", "") + + "\nconst doc: SheetDoc = { 열: [] };" + + "\nconst a = addFirstColumn(doc, 4, '새 열');" + + "\nconsole.log(JSON.stringify([a, doc.열[0].머리, doc.열[0].꼴, doc.열.length]));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["c1", ["새 열", "", "", ""], "수", 1] + + +def test_읽기만_표는_단추_고치기_모두_막음(): + sheet = _text(SHEET / "ui_template_sheet.ts") + assert "const ro = !!opts.readOnly;" in sheet + assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7 + # 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열 + assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet + assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts")) + assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css") diff --git a/ui_template/sheet/ui_template_sheet.css b/ui_template/sheet/ui_template_sheet.css index 47c2868e..ed75a946 100644 --- a/ui_template/sheet/ui_template_sheet.css +++ b/ui_template/sheet/ui_template_sheet.css @@ -181,3 +181,8 @@ font: inherit; text-align: inherit; } + +/* 읽기만 표 — 단추 줄 없음(`display:flex` 가 hidden 을 덮지 않게) */ +.ui-sheet__toolbar[hidden] { + display: none; +} diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index e26ceedd..c91e3704 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -8,6 +8,8 @@ * 도면 · 면적산출 · 재료산출 줄 = `drawingLabel` · `areaLabel` · `materialLabel` 글 · * 칸을 고르면 `onSelect` · `onPick` 을 주면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 * 치지 않고 조회 후 선택. + * `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 · + * 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기). * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -17,6 +19,7 @@ import { createButton, el } from "@ui/ui_template_elements"; import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells"; import { + addFirstColumn, addLevel, deleteColumn, isBlankPick, @@ -44,6 +47,8 @@ export type { SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_ export interface SheetOptions { mode: SheetMode; + /** 볼 수만 있음(단추 · 고치기 없음) */ + readOnly?: boolean; onChange?: (doc: SheetDoc) => void; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; @@ -80,12 +85,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio materialLabel: opts.materialLabel, pick: !!opts.onPick, }; + const ro = !!opts.readOnly; const scroll = el("div", { className: "ui-sheet__scroll" }); - const toolbar = el("div", { className: "ui-sheet__toolbar" }); + const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} }); const root = el("div", { className: `ui-sheet ui-sheet--${opts.mode}`, attrs: { tabindex: "0" }, - children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll], + children: [ + toolbar, + ...(ro ? [] : [el("p", { className: "ui-sheet__hint", text: st("Hint") })]), + scroll, + ], }); host.replaceChildren(root); @@ -96,6 +106,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio createButton({ label, variant: "ghost", onClick: run }); /** `level` 층 새 칸 열 — 고른 열 무리 끝에(층 이름이 있으면 「새 분류2」 · 없으면 「새 열」) */ const addAt = (level: number): void => { + if (!state.doc.열.length) return void addFirst(); const at = state.sel?.c; if (!at) return; const name = state.doc.층?.[level]?.trim(); @@ -105,6 +116,14 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id }; changed(); }; + /** 열 0 — 첫 열을 만들고 머리 글을 바로 고치게 엶(종류/분류 는 `/` 로 이음) */ + const addFirst = (): void => { + const id = addFirstColumn(state.doc, headDepth(state.doc.열, state.doc.층), st("Col_New")); + state.sel = { r: KEY_UNIT, c: id }; + changed(); + const th = scroll.querySelector(`th[data-col="${CSS.escape(id)}"]`); + if (th) editHead(th); + }; const colDelete = button(st("Col_Delete"), () => { const id = state.sel?.c; if (!id || state.doc.열.length <= 1) return; @@ -114,7 +133,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null; changed(); }); - if (opts.mode === "master") toolbar.append(colDelete); + if (opts.mode === "master" && !ro) toolbar.append(colDelete); // 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 · // 층 이름 · 층 수가 바뀌면 다시 만듦 @@ -122,8 +141,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio let levelKey = ""; const syncLevels = (hasCol: boolean): void => { const depth = headDepth(state.doc.열, state.doc.층); - const levels = depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1); + const empty = !state.doc.열.length; // 열 0 = 「열 더하기」 하나만(첫 열) + const levels = empty || depth < 2 ? [0] : Array.from({ length: depth - 1 }, (_, i) => i + 1); const labels = levels.map((level) => { + if (empty) return st("Col_Add"); const name = state.doc.층?.[level]?.trim(); return name ? `${name}${st("Add_Suffix")}` : st("Col_Add"); }); @@ -133,12 +154,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio levelButtons = levels.map((level, i) => button(labels[i], () => addAt(level))); colDelete.before(...levelButtons); } - for (const b of levelButtons) b.disabled = !hasCol; + for (const b of levelButtons) b.disabled = !hasCol && !empty; }; const syncToolbar = (): void => { const col = state.sel ? colOf(state.sel.c) : undefined; - if (opts.mode === "master") syncLevels(!!col); + if (opts.mode === "master" && !ro) syncLevels(!!col); colDelete.disabled = !col || state.doc.열.length <= 1; }; @@ -197,7 +218,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio return raw === null || raw === undefined ? "" : String(raw); }; const write = (key: string, col: SheetColumn, text: string): void => { - if (REF_ROWS.includes(key)) return; // 읽기만 + if (ro || REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; else if (key === KEY_PRICE) col.일위대가 = value || null; @@ -213,6 +234,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio }; /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ const edit = (initial?: string, atEnd = false): void => { + if (ro) return; const sel = state.sel; const col = sel && colOf(sel.c); if (!sel || !col) return; @@ -272,6 +294,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (target.closest("input")) return; const th = target.closest("th[data-col]"); const layer = target.closest("th[data-layer]"); + if (ro) return; if (th && state.mode === "master") editHead(th); else if (layer && state.mode === "master") editLayer(layer); else if (target.closest("td[data-r]")) edit(); @@ -310,7 +333,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio edit(undefined, true); } else if (event.key === "Delete" || event.key === "Backspace") { const col = state.sel && colOf(state.sel.c); - if (state.sel && col && keyEditable(state, state.sel.r, col)) { + if (!ro && state.sel && col && keyEditable(state, state.sel.r, col)) { event.preventDefault(); write(state.sel.r, col, ""); } @@ -325,7 +348,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const onPointerDown = (event: PointerEvent): void => { const handle = (event.target as HTMLElement).closest("[data-resize]"); const col = handle && colOf(handle.dataset.resize!); - if (!handle || !col) return; + if (ro || !handle || !col) return; event.preventDefault(); handle.setPointerCapture(event.pointerId); const total = parseFloat(scroll.querySelector("table")?.style.width ?? "0"); diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index ca4acd1c..0347aa99 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -133,6 +133,18 @@ export function addLevel( return id; } +/** 열이 하나도 없는 표의 첫 열 — `[label, "" …]`(아래 층은 빈칸) · 새 열 id. */ +export function addFirstColumn(doc: SheetDoc, depth: number, label: string): string { + const id = freshId( + "c", + doc.열.map((c) => c.id), + ); + const head: (string | null)[] = [label]; + while (head.length < depth) head.push(""); + doc.열.push({ id, 머리: head, 단위: null, 꼴: "수", 일위대가: null, 자재조합: null }); + return id; +} + /** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */ export function setLayerName(doc: SheetDoc, level: number, text: string): void { const layers = (doc.층 ??= []); From 5f43ef85414a7e018fbb626d4e5b1a5348080df4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 13:44:39 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B05=C2=B7B06):=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=A7=80=EC=A0=95=EC=9D=B4=20=ED=8F=BC=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=97=90=20=EB=A7=89=ED=98=80=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=EC=97=90=20=EC=95=88=20=EB=93=A4=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EB=8D=98=20=ED=9D=A0=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=A0=84?= =?UTF-8?q?=20B05=E2=86=92B06=E2=86=92B05=20=EC=97=90=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=20=EC=A7=80=EC=A0=95=EC=9D=B4=20=EC=98=9B=EA=B0=92=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EC=95=84=EA=B0=80=EB=8D=98=20=ED=9D=A0(21?= =?UTF-8?q?-10=20=ED=9D=A0=202=C2=B73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흠 2 고치는 구조물의 집계 항목 지정 · 해제는 필수 옵션 · 측점 검증을 거치지 않고 기록에 바로 반영(commitSummaryOnly) · 관 · 새 항목은 종전 흐름 - 흠 3 배수유역 화면이 들어올 때 세션 초안 관 목록이 있으면 그것으로 세움(초안이 없으면 종전대로 정본) - 시험 2개 추가(집계 항목 전용 반영 · 초안 우선 진입) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 13 +- .../B05_Profile_UI_Structures_Panel.ts | 13 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 16 ++ .../helper_b05_structures_form_summary.cjs | 166 +++++++++++++++--- .../test_b05_structures_form_summary.py | 24 +++ 5 files changed, 206 insertions(+), 26 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 5343a951..e48a0979 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -31,7 +31,7 @@ import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples" import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; -import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; @@ -419,8 +419,15 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra ); } - /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */ - const loadSaved = (): Promise => run(() => fetchDetailPipePoints(projectId as string)); + /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이 + * 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */ + const loadSaved = (): Promise => + run(() => { + const pending = readPendingPipes(projectId as string); + return pending?.length + ? computeDetailBasins(projectId as string, pending) + : fetchDetailPipePoints(projectId as string); + }); /** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때 * 옛 관이 살아난다(2026-08-02 사용자 보고). */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index eb4ab118..a8786505 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -32,6 +32,7 @@ import { } from "./B05_Profile_UI_Structures_Panel_Events"; import { commit as commitInto, + commitSummaryOnly, emit as emitFrom, loadForm as loadFormInto, loadPipeForm as loadPipeFormInto, @@ -358,6 +359,14 @@ export function createStructuresSection( }); } + /** 집계 항목 지정 · 해제 반영 — 고치는 구조물이면 폼 검증 없이 기록에 바로, 그 밖은 실시간 반영. */ + function commitSummary(): void { + applyingLive = true; + const done = commitSummaryOnly(panelContext); + applyingLive = false; + if (!done) liveCommit(); + } + function syncButtons(): void { primary.textContent = tempPipeChainage !== null @@ -478,12 +487,12 @@ export function createStructuresSection( void openItemPicker({ current: summary.get(), canCopy: true }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); - liveCommit(); + commitSummary(); }); }, clear: () => { summary.set(null); - liveCommit(); + commitSummary(); }, }); body.insertBefore(field("메모", memoField), actions); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 0bec107d..7509b7ef 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -190,6 +190,22 @@ export function emit(ctx: StructuresCommitContext): void { ctx.callbacks.onChange([...ctx.structures]); } +/** 집계 항목만 바뀐 때 — 폼 검증(필수 옵션 · 측점)에 걸려 조용히 버려지지 않게, 고치는 중인 + * 구조물 기록에 곧바로 반영한다(PLAN 21-10 흠 2). 반영했으면 true — 관 · 새 항목은 false 라 + * 종전 흐름(`commit(true)` · [추가])을 따른다. */ +export function commitSummaryOnly(ctx: StructuresCommitContext): boolean { + const id = ctx.editingId(); + const index = id ? ctx.structures.findIndex((entry) => entry.structure_id === id) : -1; + if (index < 0 || ctx.tempPipeChainage() !== null) return false; + const next: StructureInstance = { ...ctx.structures[index] }; + const summary = ctx.summary(); + if (summary) next.summary_item = summary; + else delete next.summary_item; + ctx.structures[index] = next; + emit(ctx); + return true; +} + export async function commit(ctx: StructuresCommitContext, live = false): Promise { const type = ctx.currentType(); if (!type) return; diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index b7c40242..0d3fa761 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -49,7 +49,11 @@ class FakeNode { return this._text + this.children.map((c) => c.textContent).join(""); } append(...kids) { - kids.forEach((k) => this.children.push(typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k)); + kids.forEach((k) => + this.children.push( + typeof k === "string" ? Object.assign(new FakeNode("#text"), { _text: k }) : k, + ), + ); } replaceChildren(...kids) { this.children = []; @@ -72,7 +76,13 @@ globalThis.Option = function Option(text, value) { }; globalThis.requestAnimationFrame = (fn) => fn(); const session = new Map(); -globalThis.window = { sessionStorage: { getItem: (k) => session.get(k) ?? null, setItem: (k, v) => session.set(k, String(v)), removeItem: (k) => session.delete(k) } }; +globalThis.window = { + sessionStorage: { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), + }, +}; globalThis.sessionStorage = globalThis.window.sessionStorage; const STUBS = { @@ -110,38 +120,85 @@ const { renderOptionFields } = B05("B05_Profile_UI_Structures_Panel_Options"); const commitModule = B05("B05_Profile_UI_Structures_Panel_Commit"); const out = {}; -const A = { layer: "personal", owner: "삼성/홍길동", key: "SS000007", name: "돌기슭막이 메쌓기 1.2m" }; +const A = { + layer: "personal", + owner: "삼성/홍길동", + key: "SS000007", + name: "돌기슭막이 메쌓기 1.2m", +}; const B = { layer: "system", owner: null, key: "SS000004", name: "옹벽 콘크리트" }; // ── 순수 규칙 ─────────────────────────────────────────────────────────── -out.visible = [logic.isOptionVisible({}), logic.isOptionVisible({ visible: true }), logic.isOptionVisible({ visible: false })]; -const before = { type_id: "wall", options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" } }; +out.visible = [ + logic.isOptionVisible({}), + logic.isOptionVisible({ visible: true }), + logic.isOptionVisible({ visible: false }), +]; +const before = { + type_id: "wall", + options: { H: 2, stone_kind: "화강암", back_length_m: 0.6, memo_only: "x" }, +}; out.mergeSame = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], { H: 3 }); out.mergeCleared = logic.mergeStructureOptions(before, "wall", ["H", "stone_kind"], {}); out.mergeTypeChange = logic.mergeStructureOptions(before, "fence", ["H"], { H: 1 }); out.mergeNoPrev = logic.mergeStructureOptions(null, "wall", ["H"], { H: 9 }); out.mergePrevNoOptions = logic.mergeStructureOptions({ type_id: "wall" }, "wall", ["H"], { H: 9 }); -out.isItem = [item.isSummaryItem(A), item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), item.isSummaryItem(null), item.isSummaryItem({ ...A, key: "" })]; +out.isItem = [ + item.isSummaryItem(A), + item.isSummaryItem({ layer: "x", owner: null, key: "K", name: "n" }), + item.isSummaryItem(null), + item.isSummaryItem({ ...A, key: "" }), +]; out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")]; -out.same = [item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), item.sameSummaryItem(A, B), item.sameSummaryItem(null, null), item.sameSummaryItem(A, null)]; +out.same = [ + item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }), + item.sameSummaryItem(A, B), + item.sameSummaryItem(null, null), + item.sameSummaryItem(A, null), +]; out.label = [item.summaryLabel(null), item.summaryLabel(A)]; out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)]; // 관 계열 merge — 집계 항목 이어받기 · 지우기 const pipePrev = { facility: "pipe", options: { pipe_kind: "흄관", extra: 1 }, summary_item: A }; -out.pipeKeep = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }).summary_item ?? null; -out.pipeClear = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: null }).summary_item; -out.pipeSet = merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: {}, summary_item: B }).summary_item.key; +out.pipeKeep = + merge.mergeFacilityOptions(pipePrev, { facility: "pipe", options: { pipe_kind: "VR관" } }) + .summary_item ?? null; +out.pipeClear = merge.mergeFacilityOptions(pipePrev, { + facility: "pipe", + options: {}, + summary_item: null, +}).summary_item; +out.pipeSet = merge.mergeFacilityOptions(pipePrev, { + facility: "pipe", + options: {}, + summary_item: B, +}).summary_item.key; // 집계 항목 줄 const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children; -out.rowEmpty = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), label: labelNode.textContent, pick: pickBtn.textContent, clear: clearBtn.textContent }; +out.rowEmpty = { + name: nameNode.textContent, + assigned: nameNode.classList.contains("is-assigned"), + clearDisabled: clearBtn.disabled, + get: row.get(), + label: labelNode.textContent, + pick: pickBtn.textContent, + clear: clearBtn.textContent, +}; row.set(A); -out.rowSet = { name: nameNode.textContent, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, nameShowsKey: nameNode.textContent.includes("SS000007") }; +out.rowSet = { + name: nameNode.textContent, + assigned: nameNode.classList.contains("is-assigned"), + clearDisabled: clearBtn.disabled, + title: nameNode.title, + key: row.get().key, + nameShowsKey: nameNode.textContent.includes("SS000007"), +}; pickBtn.click(); clearBtn.click(); out.clicks = clicks; @@ -150,11 +207,22 @@ out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled // 목록 줄 — 지정 색 · 툴팁 const list = new FakeNode("ul"); -const types = new Map([["wall", { name: "옹벽" }], ["fence", { name: "울타리" }], ["pipe", { name: "배수관" }]]); +const types = new Map([ + ["wall", { name: "옹벽" }], + ["fence", { name: "울타리" }], + ["pipe", { name: "배수관" }], +]); renderStructureList({ list, structures: [ - { structure_id: "s1", type_id: "wall", placement: "point", chainage_m: 100, options: {}, summary_item: A }, + { + structure_id: "s1", + type_id: "wall", + placement: "point", + chainage_m: 100, + options: {}, + summary_item: A, + }, { structure_id: "s2", type_id: "fence", placement: "point", chainage_m: 200, options: {} }, ], pipeFacilities: [ @@ -168,10 +236,21 @@ renderStructureList({ onSelectStructure() {}, onSelectPipe() {}, }); -out.list = list.children.map((li) => ({ assigned: li.classList.contains("is-assigned"), title: li.title })); +out.list = list.children.map((li) => ({ + assigned: li.classList.contains("is-assigned"), + title: li.title, +})); // 옵션 칸 그리기 — visible:false 숨김 · 상세(detail) 는 includeDetail 일 때만 · 정의는 그대로 -const opt = (key, extra = {}) => ({ key, label: key, input: "number", choices: [], unit: null, default: null, ...extra }); +const opt = (key, extra = {}) => ({ + key, + label: key, + input: "number", + choices: [], + unit: null, + default: null, + ...extra, +}); const wallType = { type_id: "wall", options: [ @@ -206,7 +285,13 @@ out.definitionKept = wallType.options.map((o) => o.key); // 정의는 그대로 // [수정]/[추가] 저장 — 폼 값이 정본 기록으로 나가는 자리(Commit.commit)를 fake 폼으로 돌림 const saveType = { type_id: "wall", placement: "point", managed_by: null, options: [] }; -async function saveWith({ previous, summary, formOptions }) { +async function saveWith({ + previous, + summary, + formOptions, + requiredEmpty = false, + summaryOnly = false, +}) { const structures = previous ? [previous] : []; let summaryValue = summary; let changed = null; @@ -221,10 +306,10 @@ async function saveWith({ previous, summary, formOptions }) { facilityOptions: {}, optionInputs: Object.entries(formOptions).map(([key, value]) => ({ key, - required: false, - input: {}, + required: requiredEmpty, + input: { focus() {} }, read: () => value, - isEmpty: () => false, + isEmpty: () => requiredEmpty, })), pipeFacilities: () => [], interval: () => 20, @@ -252,8 +337,12 @@ async function saveWith({ previous, summary, formOptions }) { summary: () => summaryValue, setSummary: (value) => (summaryValue = value), }; + if (summaryOnly) { + const done = commitModule.commitSummaryOnly(ctx); + return { done, record: changed ? changed[0] : null }; + } await commitModule.commit(ctx, true); - return changed[0]; + return changed ? changed[0] : null; } const prev = () => ({ structure_id: "s1", @@ -278,6 +367,41 @@ async function main() { out.commitKept = { key: kept.summary_item?.key, options: kept.options }; const swapped = await saveWith({ previous: prev(), summary: B, formOptions: { H: 3 } }); out.commitSwapped = swapped.summary_item?.key; + // 집계 항목만 바뀐 때(PLAN 21-10 흠 2) — 필수 옵션이 비어 폼 검증에 걸려도 기록에 바로 반영 + const blocked = await saveWith({ + previous: prev(), + summary: B, + formOptions: { H: 3 }, + requiredEmpty: true, + }); + out.formBlocks = blocked === null; // 종전 흐름(commit)은 필수 옵션이 비면 아무것도 안 함 + const only = await saveWith({ + previous: prev(), + summary: B, + formOptions: { H: 3 }, + requiredEmpty: true, + summaryOnly: true, + }); + out.summaryOnlyAssign = { + done: only.done, + key: only.record?.summary_item?.key, + options: only.record?.options, + }; + const onlyClear = await saveWith({ + previous: prev(), + summary: null, + formOptions: { H: 3 }, + requiredEmpty: true, + summaryOnly: true, + }); + out.summaryOnlyClear = { done: onlyClear.done, hasKey: "summary_item" in onlyClear.record }; + const onlyNew = await saveWith({ + previous: null, + summary: B, + formOptions: { H: 3 }, + summaryOnly: true, + }); + out.summaryOnlyNew = onlyNew.done; // 새 항목은 [추가] 몫 — 반영 안 함 process.stdout.write(JSON.stringify(out)); } main(); diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index 991b102c..2b7f8d03 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -112,3 +112,27 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐( "options": {"hidden_cost_key": "keep", "detail_only": 9, "H": 3}, } assert out["commitSwapped"] == "SS000004" + + +def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out): + # 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤 + assert out["formBlocks"] is True + # 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로 + assert out["summaryOnlyAssign"] == { + "done": True, + "key": "SS000004", + "options": {"H": 2, "hidden_cost_key": "keep", "detail_only": 9}, + } + assert out["summaryOnlyClear"] == {"done": True, "hasKey": False} + assert out["summaryOnlyNew"] is False + + +def test_배수유역_화면은_들어올_때_세션_초안_관을_먼저_씀(): + # 흠 3 — B05→B06→B05 를 오가도 저장 전 지정 · 옵션이 옛값으로 안 돌아감 + source = open( + os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Panel.ts"), + encoding="utf-8", + ).read() + body = source.split("const loadSaved", 1)[1].split("/**", 1)[0] + assert "readPendingPipes(" in body and "computeDetailBasins(" in body + assert body.index("readPendingPipes(") < body.index("fetchDetailPipePoints(")