From 5638bd510137c70ef031a441b92061a2bfbfffde Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 17:36:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=20=EA=B0=92=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=EC=9D=B4=20=EC=A0=80=EC=9E=A5=20=EC=A0=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=ED=8C=A8=EB=84=90=20=ED=91=9C=EC=97=90=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EB=B9=84=EC=B9=A8=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EB=B3=B4=EB=82=B4=EA=B8=B0=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=EB=8A=94=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EA=B0=80=20=EA=B8=B0=EB=A1=9D(112-1=C2=B7112?= =?UTF-8?q?-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 112-1 면적 표 변환을 공용 파일(spreadsheet_area_table.ts)로 빼 서버 Node 와 브라우저가 한 벌로 씀 · 도면 패널이 칸 고침마다(값 바뀐 표만 · 펼친 동안만) CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 보냄 - 112-2 보낸 범위 이름은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(놓기만 하고 저장 안 하면 안 생김) · [보내기 취소]는 화면에서만 빼고 면적 [저장] 때 서버가 연결표 줄 · 이름을 뺌 · /name · /unlink 길 삭제 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../spreadsheet/spreadsheet_area_table.ts | 147 ++++++++++++ .../integration/aislo-table-bridge.test.ts | 29 ++- .../src/integration/aislo-table-bridge.ts | 8 +- M02_MasterTemplete/M02_Area_Table.py | 102 +++++--- .../M02_MasterTemplete_AreaDrawing.ts | 61 ++++- .../M02_MasterTemplete_AreaPanel.ts | 13 +- .../M02_MasterTemplete_AreaToDrawing.ts | 82 +++---- .../M02_MasterTemplete_Drawing.ts | 6 +- .../M02_MasterTemplete_Router_Items.py | 46 ---- .../M02_MasterTemplete_Router_Layers.py | 16 +- .../M02_MasterTemplete_Store.py | 27 ++- .../M02_MasterTemplete_UI_Main.ts | 9 +- common_util/common_util_spreadsheet_node.ts | 152 +----------- resources/tester/test_m02_area_live.py | 224 ++++++++++++++++++ resources/tester/test_m02_area_table.py | 136 +++++++++-- resources/tester/test_m02_area_to_drawing.py | 50 +++- ui_template/cad_host/cad_host.ts | 11 +- 17 files changed, 797 insertions(+), 322 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_area_table.ts create mode 100644 resources/tester/test_m02_area_live.py diff --git a/A00_Common/spreadsheet/spreadsheet_area_table.ts b/A00_Common/spreadsheet/spreadsheet_area_table.ts new file mode 100644 index 000000000..660642655 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_area_table.ts @@ -0,0 +1,147 @@ +/* ============================================================================= + * spreadsheet_area_table.ts + * 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 한 벌의 코드를 두 곳이 씀(CLAUDE.md 5장 ②): + * 서버 Node(`common_util/common_util_spreadsheet_node.ts` · [저장] 때 정본) · + * 브라우저(M02 면적 상세 오른쪽 도면 패널 · 저장 전 실시간 표 값 · 112-1). + * 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · + * style.연결 빈 글(화면이 붙임) · 저장 안 함. + * ========================================================================== */ + +import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address"; +import { cellStyleAt } from "./spreadsheet_cellstyle"; +import { createCalcEngine } from "./spreadsheet_graph"; +import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers"; +import { resolveName } from "./spreadsheet_names"; +import { formatValue } from "./spreadsheet_numfmt"; +import type { CellRange, HAlign, Workbook } from "./spreadsheet_types"; + +/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */ +export const NAME_GONE = "이름없음"; + +/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면 + * 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */ +export interface TableAsk { + book: Workbook; + 시트: string; + 범위: string; + 이름?: string; + 글자크기?: number; + 한도?: number; +} + +/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */ +export interface TableCell { + text: string; + colSpan?: number; + rowSpan?: number; + align?: "left" | "center" | "right"; + bold?: boolean; + key: string; +} + +const FONT = 2.5; +const LIMIT = 2000; +/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */ +const COL_K = 0.7; +const ROW_K = 1.8; +const ALIGN: Partial> = { + left: "left", + center: "center", + right: "right", + distributed: "center", + centerContinuous: "center", +}; + +export function areaTable(ask: TableAsk) { + const book = ask.book; + let sheetId = ask.시트; + let range: CellRange; + if (ask.이름) { + const found = resolveName(book, ask.시트, ask.이름); + if (!found) throw new Error(NAME_GONE); // 이름을 지웠거나 범위가 통째로 지워짐 + sheetId = found.시트; + range = found.범위; + } else { + const parsed = parseRange(ask.범위); + if (!parsed) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + range = parsed; + } + const sheet = + book.시트.find((s) => s.id === sheetId) ?? book.시트.find((s) => s.이름 === sheetId); + if (!sheet) throw new Error(`없는 시트 「${sheetId}」`); + const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT; + const limit = ask.한도 ?? LIMIT; + const span = (a: number, b: number): number[] => + Array.from({ length: b - a + 1 }, (_, i) => a + i); + const rowShown = (r: number): boolean => { + const info = sheet.행?.[String(r + 1)]; + return !info?.숨김 && !info?.걸러짐; + }; + const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김; + if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4) + throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음 + const rows = span(range.r0, range.r1).filter(rowShown); + const cols = span(range.c0, range.c1).filter(colShown); + if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음"); + if (rows.length * cols.length > limit) + throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`); + const engine = createCalcEngine(book); + const merges: { whole: CellRange; part: CellRange }[] = []; + for (const text of sheet.병합 ?? []) { + const whole = parseRange(text); + if (!whole || !rangesOverlap(whole, range)) continue; + const part = { + r0: Math.max(whole.r0, range.r0), + c0: Math.max(whole.c0, range.c0), + r1: Math.min(whole.r1, range.r1), + c1: Math.min(whole.c1, range.c1), + }; // 범위 밖 병합은 잘라 냄 + merges.push({ whole, part }); + } + const mergeAt = (r: number, c: number) => + merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1); + const 값: Record = {}; + const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => { + const style = cellStyleAt(book, sheet, at.r, at.c); + const has = sheet.칸[toA1(at.r, at.c)]; + const shown = + has && (has.값 !== undefined || has.식 !== undefined) + ? formatValue(engine.value(sheet.id, at.r, at.c), style.형식) + : { 글: "", 정렬: "left" as const }; + const key = `${sheet.id}!${toA1(r, c)}`; + 값[key] = shown.글; + const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); + return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; + }; + const cells = rows.map((r) => + cols.map((c): TableCell | null => { + const merge = mergeAt(r, c); + if (!merge) return cellOf(r, c, { r, c }); + const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1); + const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1); + if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리 + const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸 + return { + ...cell, + ...(inCols.length > 1 ? { colSpan: inCols.length } : {}), + ...(inRows.length > 1 ? { rowSpan: inRows.length } : {}), + }; + }), + ); + const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS; + const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT; + const 표 = { + type: "Table", + lineWidth: 1, + shapeData: { + origin: { x: 0, y: 0 }, + columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K), + rowHeights: rows.map( + (r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K, + ), + cells, + style: { fontSize: font, padding: 1, 연결: "" }, + }, + }; + return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 }; +} diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts index f62855427..0545f764c 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -3,7 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Actor } from 'xstate'; import { TableEntity } from '../entities/TableEntity.ts'; import { ActorEvent } from '../tools/tool.types.ts'; -import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts'; +import { + getEntities, + isDrawingDirty, + setEntities, + setHostReadOnly, + setLayers, +} from '../state.ts'; import { getPendingTable, placeTableToolStateMachine, @@ -158,6 +164,27 @@ describe('표 칸 갈기(update-table)', () => { expect(getEntities()).toBe(before); }); + it('보기 전용 도면은 막고(0) · preview 로 온 갈기만 보기 전용에서도 갈음(112-1)', async () => { + const linked = table('T1'); + setEntities([linked]); + setHostReadOnly(true); + const cells = [ + [{ text: '항목' }, null], + [{ text: '면적' }, { text: '77.0' }], + ]; + const notify = vi.fn(); + await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T1', cells }, notify); + expect(notify).toHaveBeenLastCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T1', count: 0 }); + expect(linked.getCell(1, 1)?.text).toBe('12.5'); + await handleTableMessage( + { type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T1', cells, preview: true }, + notify + ); + expect(notify).toHaveBeenLastCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T1', count: 1 }); + expect(linked.getCell(1, 1)?.text).toBe('77.0'); + setHostReadOnly(false); + }); + it('표 메시지가 아니면 false', async () => { expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false); }); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts index 3ed2f4bd2..0cc046b9a 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -1,8 +1,8 @@ /** * 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`. * place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작 - * update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음) - * → 부모에 table-updated{link, count} + * update-table{link, cells, preview?} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음) + * → 부모에 table-updated{link, count} · preview 면 보기 전용이어도 갈음(M02 면적 도면 패널 · 112-1) * place-cancelled{} (CAD → 부모) 표를 놓지 않고 Esc 로 끝남 * 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함. */ @@ -25,6 +25,8 @@ interface TableMessage { entity?: JsonEntity; link?: string; cells?: TableCells; + /** update 전용 — 보기 전용 미리보기 패널이 저장 전 값을 비춤(도면 문서는 안 고침 · 보기 전용이어도 갈음) */ + preview?: boolean; } /** 이 표가 그 연결의 것인가 */ @@ -77,7 +79,7 @@ export async function handleTableMessage( return true; } if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) { - const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells); + const count = message.preview || !blocked() ? updateLinkedTables(message.link, message.cells) : 0; notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count }); return true; } diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py index d56f1aadb..50c5139db 100644 --- a/M02_MasterTemplete/M02_Area_Table.py +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -22,7 +22,8 @@ from M02_MasterTemplete.M02_Template_Layers import drawing_sheets logger = logging.getLogger(__name__) LINKS = "연결표" DROPPED = "지운연결" -# common_util_spreadsheet_node.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭 +CANCELS = "보내기취소" # 면적 [저장] 문서의 임시 칸 — 저장 안 함(112-2) +# spreadsheet_area_table.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭 NAME_GONE = "이름없음" @@ -43,35 +44,69 @@ def to_table( return {k: got[0][k] for k in ("표", "시트", "범위", "값")} -def define_name(column: str, sheet: str, rng: str, name: str) -> None: - """처음 도면으로 보낼 때(T 번호 정해진 뒤) 면적 시트에 보낸 범위 이름을 더함/갱신(104-1) — - `applyLinked`(행열 넣기 · 지우기)가 이 이름을 그대로 따라가게 함.""" - book = store.read("area", column)["문서"] - try: - got = sheet_node.define_names([{"book": book, "시트": sheet, "범위": rng, "이름": name}]) - except sheet_node.RecalcError as e: - raise store.StoreError(503, str(e)) from e - if not got[0].get("ok"): - raise store.StoreError(400, got[0].get("까닭")) +def fresh_names(path: Any, doc: dict[str, Any]) -> list[dict[str, Any]]: + """도면 [저장] 문서의 새 연결 줄(저장돼 있던 파일에 없던 `연결`) 중 `이름`이 있는 것 — + 면적 문서에 둘 이름(112-2).""" + old: set[Any] = set() + if path.is_file(): + old = { + x.get("연결") + for x in json.loads(path.read_bytes()).get(LINKS) or [] + if isinstance(x, dict) + } + rows = [x for x in doc.get(LINKS) or [] if isinstance(x, dict)] + return [x for x in rows if x.get("이름") and x.get("연결") not in old] + + +def define_names(column: str, rows: list[dict[str, Any]]) -> None: + """도면 [저장] 요청 안에서(112-2) 새 연결 줄의 보낸 범위 이름을 면적 시트에 더함/갱신(104-1) — + `applyLinked`(행열 넣기 · 지우기)가 이 이름을 따라가게 함. 면적 문서가 없거나 못 풀면 건너뜀 + (이름 없는 연결은 적힌 범위로 감).""" path = store._path("area", column) - with store._LOCK: - atomic_write_json(path, got[0]["book"]) # 디스크 글 자체가 책(`store.read` 의 "문서") - - -def unlink(column: str, 연결: str) -> None: - """이름표 메뉴 [보내기 취소](104-3) — 연결표에서 그 줄만 빼고 저장 · 도면 표(CAD 엔티티)는 그대로 남음 - (지우기는 도면에서 — `withLinks` 가 다음 저장 때 표 없는 기록을 거둠).""" - path = store._path("structure", column) - if not path.is_file(): + if not rows or not path.is_file(): + return + book = json.loads(path.read_bytes()) + try: + for row in rows: + ask = { + "book": book, + "시트": row.get("시트"), + "범위": row.get("범위"), + "이름": row["이름"], + } + got = sheet_node.define_names([ask])[0] + if got.get("ok"): + book = got["book"] + else: + logger.warning( + "보낸 범위 이름 건너뜀 %s %s — %s", column, row["이름"], got.get("까닭") + ) + except sheet_node.RecalcError as e: + logger.warning("보낸 범위 이름 건너뜀 %s — %s", column, e) return with store._LOCK: - doc = json.loads(path.read_bytes()) - rows = doc.get(LINKS) or [] - kept = [x for x in rows if x.get("연결") != 연결] - if len(kept) == len(rows): - return - doc[LINKS] = kept - atomic_write_json(path, doc) + atomic_write_json(path, book) # 디스크 글 자체가 책(`store.read` 의 "문서") + + +def apply_cancels(column: str, doc: Any) -> tuple[Any, list[str]]: + """면적 [저장] 문서의 `보내기취소[]`(112-2 · 이름표 메뉴 [보내기 취소])를 떼어 냄 — 그 연결 줄의 `이름`을 + 문서의 이름정의에서 뺌 → (문서, 연결 목록). 줄을 빼는 일은 `refresh_drawing(unlink=…)`.""" + if not isinstance(doc, dict) or CANCELS not in doc: + return doc, [] + ids = [x for x in doc[CANCELS] or [] if isinstance(x, str)] + doc = {k: v for k, v in doc.items() if k != CANCELS} + path = store._path("structure", column) + rows = json.loads(path.read_bytes()).get(LINKS) or [] if ids and path.is_file() else [] + names = {x.get("이름") for x in rows if isinstance(x, dict) and x.get("연결") in ids} + names.discard(None) + if names: + doc["시트"] = [ + {**s, "이름정의": [n for n in s["이름정의"] if n.get("이름") not in names]} + if isinstance(s, dict) and s.get("이름정의") + else s + for s in doc.get("시트") or [] + ] + return doc, ids def _style_link(entity: Any) -> Any: @@ -80,8 +115,10 @@ def _style_link(entity: Any) -> Any: return ((entity.get("shapeData") or {}).get("style") or {}).get("연결") -def refresh_drawing(column: str) -> list[str]: +def refresh_drawing(column: str, unlink: Any = ()) -> list[str]: """지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록. + `unlink` = 면적 [저장] 때 [보내기 취소]한 연결 — 그 줄을 연결표에서 뺌 + (도면 표는 남음 · 알림 없음 · 112-2). 이름(104-1)이 있으면 그 이름이 지금 가리키는 범위로 `범위` 도 갱신 — 이름이 지워졌으면(범위가 통째로 지워짐) 그 연결을 끊고(도면 표는 남음) `지운연결` 목록에 적어 다음에 열 때 알림.""" try: @@ -91,8 +128,13 @@ def refresh_drawing(column: str) -> list[str]: if not drawing.is_file() or not area.is_file(): return [] doc = json.loads(drawing.read_bytes()) - links = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")] + every = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")] + links = [x for x in every if x["연결"] not in unlink] if not links: + if len(links) != len(every): # 취소로 줄이 다 빠짐 + doc[LINKS] = links + with store._LOCK: + atomic_write_json(drawing, doc) return [] book = json.loads(area.read_bytes()) asks = [ @@ -139,7 +181,7 @@ def refresh_drawing(column: str) -> list[str]: if dropped: doc[LINKS] = kept doc[DROPPED] = sorted(set(doc.get(DROPPED) or []) | set(dropped)) - elif len(kept) != len(links): + elif len(kept) != len(every): doc[LINKS] = kept with store._LOCK: atomic_write_json(drawing, doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts index d9f882959..9a274dd26 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts @@ -5,12 +5,17 @@ * 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료 * (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽). * CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음. + * 112-1 — 면적 칸을 고치면(저장 전) 연결 표 값이 바로 따라 바뀜: 지금 시트의 칸 글을 서버와 같은 코드 + * (`spreadsheet_area_table.ts`)로 브라우저가 만들어 CAD 연결 표 갈기 메시지(보기 전용 갈음)로 보냄 · + * 패널이 펼쳐진 동안 · 값이 바뀐 표만 · 도면 문서는 안 고침([저장] 때는 서버 refresh_drawing 이 정본). * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; import { mountStructureSheets } from "./M02_MasterTemplete_Drawing"; import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing"; +import { sameValues, type TableLink } from "./M02_MasterTemplete_AreaToDrawing"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; const TEXT = { loading: "도면 불러오는 중…", @@ -44,12 +49,25 @@ function hideChrome(frame: HTMLIFrameElement): void { export interface AreaDrawingOptions { /** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */ onTable?: (table: DrawingTable) => void; + /** 면적 화면에서 [보내기 취소]한 연결(저장 전) — 실시간 값 갈기에서 뺌 */ + cancelled?: () => ReadonlySet; } +/** 도면 손잡이 중 실시간 갈기에 쓰는 것(`mountStructureSheets` 의 `updateTable`) */ +interface LiveHandle { + updateTable?: (link: string, cells: unknown[][], preview?: boolean) => Promise; +} + +const LIVE_MS = 150; // 칸 고침이 이어지는 동안 모았다가 한 번에 + export interface AreaDrawing { root: HTMLElement; /** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */ show: () => void; + /** 패널이 접힘 — 접힌 동안은 실시간 갈기 건너뜀 */ + hide: () => void; + /** 면적 시트가 바뀜(저장 전) — 지금 책을 주면 연결 표 값을 따라 바꿈 */ + sync: (book: () => Workbook | null | undefined) => void; destroy: () => void; } @@ -64,6 +82,12 @@ export function createAreaDrawing( let frame: HTMLIFrameElement | null = null; let started = false; let dead = false; + let open = false; + let rows: TableLink[] = []; + let bookOf: (() => Workbook | null | undefined) | null = null; + let timer: number | undefined; + /** 연결별로 마지막에 도면에 비춘 칸 글(없으면 저장된 `값`) */ + const pushed = new Map>(); const note = (text: string): void => stage.replaceChildren(el("p", { className: "m02-apanel__note", text })); @@ -85,8 +109,38 @@ export function createAreaDrawing( }; window.addEventListener("message", onMessage); + /** 지금 시트 → 연결 표마다 칸 글을 만들어 달라진 것만 도면에 비춤(서버 `areaTable` 과 같은 코드) */ + async function push(): Promise { + const book = bookOf?.(); + const handle = view as LiveHandle | null; + if (dead || !open || !book || !handle?.updateTable || !rows.length) return; + const { areaTable } = await import("../A00_Common/spreadsheet/spreadsheet_area_table"); + for (const row of rows) { + if (opts.cancelled?.().has(row.연결)) continue; + try { + const got = areaTable({ + book, + 시트: row.시트, + 범위: row.범위, + 이름: row.이름, + 글자크기: row.글자크기, + }); + if (sameValues(pushed.get(row.연결) ?? row.값, got.값)) continue; + pushed.set(row.연결, got.값); + await handle.updateTable(row.연결, got.표.shapeData.cells, true).catch(() => 0); + } catch { + // 이름이 지워짐 · 범위 못 바꿈 — 표는 그대로(저장 때 서버가 판정) + } + } + } + const later = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void push(), LIVE_MS); + }; + const show = (): void => { - if (started) return; + open = true; + if (started) return later(); // 접힌 사이 바뀐 값을 따라잡음 started = true; note(TEXT.loading); readTemplate(layer, "structure", column, null) @@ -95,6 +149,7 @@ export function createAreaDrawing( const doc = got.문서 as StructureDoc | null; if (!doc?.도면) return note(TEXT.none); stage.replaceChildren(); + rows = doc.연결표 ?? []; // 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀 const sheets = doc as unknown as Parameters[1]; view = mountStructureSheets(stage, sheets, { @@ -111,6 +166,7 @@ export function createAreaDrawing( frame = stage.querySelector("iframe"); if (frame) hideChrome(frame); send.disabled = !frame; + later(); }) .catch(() => !dead && note(TEXT.none)); }; @@ -118,8 +174,11 @@ export function createAreaDrawing( return { root, show, + hide: () => (open = false), + sync: (book) => ((bookOf = book), later()), destroy: () => { dead = true; + window.clearTimeout(timer); window.removeEventListener("message", onMessage); view?.destroy(); root.remove(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index f0de36191..4a1da3b18 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -4,7 +4,8 @@ * · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`) * · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3) * · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기) - * · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) + * · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) · + * 도면 패널의 연결 표 값도 저장 전에 바로 따라 바뀜(112-1 · `AreaDrawing.sync`) * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -36,6 +37,8 @@ export interface AreaPanelOptions { /** 알약 막대 항목 — 칸 지정 때 연결에 같이 적을 이름 · 단위 · 자재 */ pills: () => LinkItem[]; readOnly: boolean; + /** 면적 화면에서 [보내기 취소]한 연결(저장 전) — 도면 패널 실시간 갈기에서 뺌 */ + cancelled?: () => ReadonlySet; } export interface AreaPanel { @@ -99,6 +102,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { // 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음 const drawing = createAreaDrawing(opts.layer, opts.column, { onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table), + cancelled: opts.cancelled, }); const panels = createSidePanels({ prefKey: "m02-area-panels", @@ -108,13 +112,16 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { { id: "labor", title: TEXT.labor, body: view.root }, { id: "drawing", title: TEXT.drawing, body: drawing.root }, ], - onChange: (open) => open === "drawing" && drawing.show(), + onChange: (open) => (open === "drawing" ? drawing.show() : drawing.hide()), }); if (panels.current() === "drawing") drawing.show(); const root = panels.root; // 처음 = 저장 안 한 시트 칸 값으로 셈(서버 calc 한 번) - const refresh = (): void => view.setQty(quantities(opts.sheet()), cellsOf(opts.sheet())); + const refresh = (): void => { + view.setQty(quantities(opts.sheet()), cellsOf(opts.sheet())); + drawing.sync(() => opts.sheet()?.getDoc()); // 저장 전 칸 값 → 도면 패널 표(112-1) + }; void view.show(opts.layer, opts.column, quantities(opts.sheet())); refresh(); // 칸 주소는 지금 시트 것 — 보기가 저장본 대신 씀 return { diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index b0c7bfd02..873c8c2a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -4,10 +4,13 @@ * ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5) * → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열 * 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2) - * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} · - * 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) + * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 이름, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} · + * 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) · + * 새 연결 줄의 보낸 범위 이름은 이 [저장] 요청 안에서 서버가 면적 문서에 둠(112-2 · 놓고 저장 안 하면 안 생김) * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) * ④ 표 여러 개 — id 마다 한 줄 + * ⑥ 이름표 메뉴 [보내기 취소] — 화면에서만 점선 · 이름표를 빼고 면적 화면 「저장 안 한 고침」 · + * 면적 [저장] 문서에 `보내기취소[]` 를 실어 서버가 그 연결표 줄 · 이름을 뺌(도면 표는 남음 · 112-2) * ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id * (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장 * ========================================================================== */ @@ -117,25 +120,6 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise return data as AreaTable; } -/** 보낸 범위 이름을 면적 시트에 더함/갱신(104-1 · 처음 놓을 때 1번) — 실패해도 놓기는 이미 끝났으니 토스트만 */ -async function defineAreaName( - layer: Layer, - column: string, - 시트: string, - 범위: string, - 이름: string, -): Promise { - const owner = getActiveOwner(); - const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; - const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/name${q}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ 시트, 범위, 이름 }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); -} - /** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */ const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [ ["", doc.도면?.entities ?? []], @@ -149,29 +133,19 @@ const linkOf = (e: TableEntityJson): string | null => { return typeof id === "string" && id ? id : null; }; /** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */ -const sameValues = (a: Record = {}, b: Record = {}): boolean => { +export const sameValues = ( + a: Record = {}, + b: Record = {}, +): boolean => { const ka = Object.keys(a); return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); }; -/** 이름표 메뉴 [보내기 취소](104-3) — 연결표에서 그 줄만 빼고 저장(도면 표는 남음) */ -async function unlinkTable(at: Selection, 연결: string): Promise { - const owner = getActiveOwner(); - const q = owner && at.layer !== "system" && at.layer !== "project" ? `?owner=${enc(owner)}` : ""; - const res = await fetch( - `${API_BASE_URL}/m02/layers/${at.layer}/structures/${enc(at.name)}/unlink${q}`, - { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ 연결 }), - }, - ); - return res.ok; -} - /** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */ -export async function sentRanges(at: Selection): Promise> { +export async function sentRanges( + at: Selection, + onCancel: (연결: string) => void, +): Promise> { try { const got = await readTemplate(at.layer, "structure", at.name, at.projectId); const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? []; @@ -181,7 +155,7 @@ export async function sentRanges(at: Selection): Promise unlinkTable(at, row.연결).catch(() => false), + 취소: async () => (onCancel(row.연결), true), // 화면에서만 — 서버는 면적 [저장] 때 })); } catch { return []; @@ -208,8 +182,14 @@ export interface AreaToDrawing { onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void; /** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */ waiting: () => boolean; - /** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */ + /** 떠나기로 함(물음 [확인]) · 저장 끝 — 기다리던 표 · 저장 전 보내기 취소를 버림 */ drop: () => void; + /** 이름표 메뉴 [보내기 취소] — 저장 전 목록에 담음(면적 화면 「저장 안 한 고침」) */ + cancel: (연결: string) => void; + /** 저장 전 보내기 취소한 연결 */ + cancelled: () => ReadonlySet; + /** 면적 저장 문서 — 취소한 연결이 있으면 `보내기취소[]` 를 실음(서버가 줄 · 이름을 뺌) */ + withCancels: (doc: T) => T; /** 도면에서 Esc 로 놓지 않고 그만둠 — 기다림을 풀고 그 보내기 기록도 뺌 */ cancelPlace: () => void; /** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */ @@ -227,6 +207,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { /** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */ let waiting = false; let waitingId = ""; + /** 면적 화면에서 [보내기 취소]한 연결 — 면적 [저장] 때 서버가 끊음 */ + const cancels = new Set(); /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ const askSize = (spec: { 범위: string }): Promise => @@ -339,14 +321,21 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { return { select: (s) => (range = { 시트: s.시트, 범위: s.범위 }), - waiting: () => waiting, - drop: () => (waiting = false), + waiting: () => waiting || cancels.size > 0, + drop: () => ((waiting = false), cancels.clear()), + cancel: (연결) => void cancels.add(연결), + cancelled: () => cancels, + withCancels: (doc) => { + const { 보내기취소: _old, ...rest } = doc as typeof doc & { 보내기취소?: unknown }; // 앞 저장 것은 떼고 + return (cancels.size ? { ...rest, 보내기취소: [...cancels] } : rest) as typeof doc; + }, cancelPlace: () => { waiting = false; fresh.delete(waitingId); }, onArea: (at, readOnly, slot) => { waiting = false; + cancels.clear(); // 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림 if (readOnly || at.layer === "project") return; const button = createButton({ label: TEXT.send }); // 머리 일반 단추(43-5 · 돌아가기와 같은 높이) @@ -360,6 +349,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; range = null; // 면적 상세를 떠남 + cancels.clear(); fresh = new Map(); latest = new Map(); const give = @@ -377,11 +367,11 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { waitingId = id; waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂) showToast(TEXT.place, "info"); - // 보낸 범위 이름을 면적 시트에 — 행열 넣기 · 지우기를 따라가게(104-1) · 실패해도 놓기는 그대로 - void defineAreaName(at.layer, at.name, 시트, 범위, 이름).catch(() => {}); + // 보낸 범위 이름(104-1)은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(112-2 · 여기서 안 씀) } } - if (doc.지운연결?.length) showToast(fill(TEXT.rangeGone, "ids", doc.지운연결.join(" · ")), "info"); + if (doc.지운연결?.length) + showToast(fill(TEXT.rangeGone, "ids", doc.지운연결.join(" · ")), "info"); void checkStale(handle, at, doc); }, withLinks: (doc) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 5f902b05e..303480ae6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -94,7 +94,7 @@ export interface DrawingTemplateHandle { getDoc: () => Promise; /** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */ placeTable: (entity: Record, link: string) => void; - updateTable: (link: string, cells: unknown[][]) => Promise; + updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise; /** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */ loadSheet: ( drawing: DrawingTemplateDoc, @@ -537,7 +537,7 @@ export function mountStructureSheets( drawing.placeTable(entity, link); }, // 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3) - updateTable: async (link, cells) => { + updateTable: async (link, cells, preview) => { const now = tabs.active(); let count = 0; for (const tab of tabs.tabs()) { @@ -547,7 +547,7 @@ export function mountStructureSheets( count += changed; } if (count) report(); - return count + (await drawing.updateTable(link, cells).catch(() => 0)); + return count + (await drawing.updateTable(link, cells, preview).catch(() => 0)); }, destroy: drawing.destroy, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 0f0d627bf..873991b11 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -337,52 +337,6 @@ def post_area_table( ) -class AreaNameBody(BaseModel): - """보낸 범위 이름 정의/갱신(104-1 · 처음 도면으로 보낼 때 1번) — `이름` = `도면_T1` 꼴.""" - - 시트: str - 범위: str - 이름: str - - -@router.post("/layers/{layer}/area/{column}/name") -def post_area_name( - layer: Layer, - column: str, - body: AreaNameBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """면적 시트에 보낸 범위 이름을 더함/갱신 — 쓰기 권한.""" - from M02_MasterTemplete import M02_Area_Table as area_table - - folder = _root(session, layer, owner, write=True) - _call(folder, area_table.define_name, column, body.시트, body.범위, body.이름) - return {} - - -class UnlinkBody(BaseModel): - """이름표 메뉴 [보내기 취소](104-3) — `연결` = T번호.""" - - 연결: str - - -@router.post("/layers/{layer}/structures/{column}/unlink") -def post_structure_unlink( - layer: Layer, - column: str, - body: UnlinkBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """연결표에서 그 줄만 빼고 저장(104-3) — 도면 표는 남음 · 쓰기 권한.""" - from M02_MasterTemplete import M02_Area_Table as area_table - - folder = _root(session, layer, owner, write=True) - _call(folder, area_table.unlink, column, body.연결) - return {} - - @router.post("/layers/{layer}/{kind}/{column}/clear") def post_clear( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 0baf219ef..f675cfb41 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -345,7 +345,9 @@ async def save_layer_template( return await asyncio.to_thread( _store_save, folder, kind, name, body.판 or "", document, layer ) + cancels: list[str] = [] if kind == "area": + document, cancels = await asyncio.to_thread(_apply_cancels, folder, name, document) # 면적산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음 # (브라우저 값 버림 · 5장 ②) try: @@ -369,15 +371,23 @@ async def save_layer_template( except ValueError as error: raise _bad(error) from error if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가) - await asyncio.to_thread(_refresh_drawing, folder, name) + await asyncio.to_thread(_refresh_drawing, folder, name, cancels) return {"종류": kind, "이름": name, "판": version, "층": layer} -def _refresh_drawing(folder: Path, column: str) -> None: +def _apply_cancels(folder: Path, column: str, doc: Any) -> tuple[Any, list[str]]: + """면적 저장 문서의 `보내기취소[]`(112-2)를 떼고 그 이름을 이름정의에서 뺌.""" from M02_MasterTemplete import M02_Area_Table as area_table with store.at(folder): - area_table.refresh_drawing(column) + return area_table.apply_cancels(column, doc) + + +def _refresh_drawing(folder: Path, column: str, unlink: list[str] | tuple[()] = ()) -> None: + from M02_MasterTemplete import M02_Area_Table as area_table + + with store.at(folder): + area_table.refresh_drawing(column, unlink) def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index c63a4f697..67baa039e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -150,8 +150,15 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 원본 열의 일위대가 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로). `screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로 - (정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`).""" + (정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`). + 도면↔면적(112-2) — 구조물 도면 저장 때 새 연결 줄의 보낸 범위 이름을 같은 요청 안에서 면적 문서에 두고 · + 면적 저장 문서의 `보내기취소[]` 연결은 도면 연결표 줄 · 이름을 뺌.""" path = _path(kind, name) + cancels: list[str] = [] + if kind == "area": + from M02_MasterTemplete import M02_Area_Table as area_table + + doc, cancels = area_table.apply_cancels(name, doc) _check_skeleton(kind, doc) if kind in layers.LIBRARY_KINDS: # 서고 문서 키 = 파일 이름(50-3) doc = {**doc, "키": name} @@ -159,10 +166,15 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> if kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) doc = _server(kind, doc) + fresh: list[dict[str, Any]] = [] with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): raise StoreError(409, {"stale": [name], "판": have}) + if kind == "structure": + from M02_MasterTemplete import M02_Area_Table as area_table + + fresh = area_table.fresh_names(path, doc) if screen and have and kind == "table" and name == STRUCTURE_TABLE: doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes()))) if have and kind in _KEY: @@ -172,7 +184,11 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> info = _info(kind, path) if kind == "area": _refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도 - _refresh_drawing(name) # 도면 연결표 칸 글도(41-1) + _refresh_drawing(name, cancels) # 도면 연결표 칸 글도(41-1) · 보내기 취소 줄 뺌(112-2) + if fresh: + from M02_MasterTemplete import M02_Area_Table as area_table + + area_table.define_names(name, fresh) # 새 연결 줄의 보낸 범위 이름(112-2) if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: @@ -562,11 +578,12 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any: return doc -def _refresh_drawing(column: str) -> None: - """그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1).""" +def _refresh_drawing(column: str, unlink: list[str] | tuple[()] = ()) -> None: + """그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1) · + `unlink` = 보내기 취소 줄.""" from M02_MasterTemplete import M02_Area_Table as area_table - area_table.refresh_drawing(column) + area_table.refresh_drawing(column, unlink) def _refresh_labor(column: str) -> None: diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 7154d1020..4c8f5d10d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -298,7 +298,7 @@ export function createMain( const [{ createSpreadsheet }, pills, sent] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), pillsFor(kind, at.name), - sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리 + sentRanges(at, toDrawing.cancel), // 도면으로 보낸 범위 — 시트 위 점선 테두리 ]); if (mine !== seq) return; if (pills.notice) showNotice([pills.notice]); @@ -332,6 +332,7 @@ export function createMain( picked: () => picked, pills: () => links.항목, readOnly, + cancelled: toDrawing.cancelled, }); host.append(apanel.root); return; @@ -555,12 +556,16 @@ export function createMain( ...(edited as Pick), }) : at.kind === "area" && loaded - ? { ...(loaded as Record), ...(edited as Record) } + ? toDrawing.withCancels({ + ...(loaded as Record), + ...(edited as Record), + }) // + 저장 전 [보내기 취소] 연결(112-2) : edited; const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc); version = info.판; loaded = doc; dirty = false; + toDrawing.drop(); // 놓기 기다림 · 보내기 취소는 이 저장으로 끝 // 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로 // (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸) if (panel && sheet) { diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index 285302462..d669a82e9 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -9,43 +9,19 @@ * 입력 { books?: Workbook[], tables?: TableAsk[] } * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[], * tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂 - * 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · - * 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함. + * 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 풀이는 `A00_Common/spreadsheet/spreadsheet_area_table.ts` + * (화면 도면 패널의 저장 전 실시간 표 값과 같은 코드 · 112-1) · 저장 안 함. * 끝 코드: 0 성공 / 2 인자 오류 * ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게. * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; -import { - colName, - parseRange, - rangeToA1, - rangesOverlap, - toA1, -} from "../A00_Common/spreadsheet/spreadsheet_address"; -import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle"; -import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph"; -import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers"; -import { resolveName } from "../A00_Common/spreadsheet/spreadsheet_names"; -import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt"; +import { parseRange } from "../A00_Common/spreadsheet/spreadsheet_address"; +import { areaTable, type TableAsk } from "../A00_Common/spreadsheet/spreadsheet_area_table"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; -/** 범위 대신 이름을 줬는데 그 이름이 지금 책에 없음(104-1 · 범위가 통째로 지워짐) — python 쪽이 문자열로 가름 */ -export const NAME_GONE = "이름없음"; - -/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12`(이름 없을 때만 씀) · `이름` 이 있으면 - * 그 이름이 지금 가리키는 범위로 품(104-1 · 행열 넣기 · 지우기를 따라간 뒤 범위) · `글자크기` = 도면 단위(기본 2.5) */ -interface TableAsk { - book: Workbook; - 시트: string; - 범위: string; - 이름?: string; - 글자크기?: number; - 한도?: number; -} - -/** 면적 시트에 보낸 범위 이름을 더하거나(같은 이름 있으면) 범위를 갱신(104-1 · 처음 보낼 때 1번) */ +/** 면적 시트에 보낸 범위 이름을 더하거나(같은 이름 있으면) 범위를 갱신(104-1 · 도면 [저장] 때 새 연결 줄마다) */ interface NameAsk { book: Workbook; 시트: string; @@ -53,122 +29,6 @@ interface NameAsk { 이름: string; } -/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */ -interface TableCell { - text: string; - colSpan?: number; - rowSpan?: number; - align?: "left" | "center" | "right"; - bold?: boolean; - key: string; -} - -const FONT = 2.5; -const LIMIT = 2000; -/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */ -const COL_K = 0.7; -const ROW_K = 1.8; -const ALIGN: Partial> = { - left: "left", - center: "center", - right: "right", - distributed: "center", - centerContinuous: "center", -}; - -function areaTable(ask: TableAsk) { - const book = ask.book; - let sheetId = ask.시트; - let range: CellRange; - if (ask.이름) { - const found = resolveName(book, ask.시트, ask.이름); - if (!found) throw new Error(NAME_GONE); // 이름을 지웠거나 범위가 통째로 지워짐 - sheetId = found.시트; - range = found.범위; - } else { - const parsed = parseRange(ask.범위); - if (!parsed) throw new Error(`범위 꼴 아님 「${ask.범위}」`); - range = parsed; - } - const sheet = book.시트.find((s) => s.id === sheetId) ?? book.시트.find((s) => s.이름 === sheetId); - if (!sheet) throw new Error(`없는 시트 「${sheetId}」`); - const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT; - const limit = ask.한도 ?? LIMIT; - const span = (a: number, b: number): number[] => - Array.from({ length: b - a + 1 }, (_, i) => a + i); - const rowShown = (r: number): boolean => { - const info = sheet.행?.[String(r + 1)]; - return !info?.숨김 && !info?.걸러짐; - }; - const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김; - if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4) - throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음 - const rows = span(range.r0, range.r1).filter(rowShown); - const cols = span(range.c0, range.c1).filter(colShown); - if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음"); - if (rows.length * cols.length > limit) - throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`); - const engine = createCalcEngine(book); - const merges: { whole: CellRange; part: CellRange }[] = []; - for (const text of sheet.병합 ?? []) { - const whole = parseRange(text); - if (!whole || !rangesOverlap(whole, range)) continue; - const part = { - r0: Math.max(whole.r0, range.r0), - c0: Math.max(whole.c0, range.c0), - r1: Math.min(whole.r1, range.r1), - c1: Math.min(whole.c1, range.c1), - }; // 범위 밖 병합은 잘라 냄 - merges.push({ whole, part }); - } - const mergeAt = (r: number, c: number) => - merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1); - const 값: Record = {}; - const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => { - const style = cellStyleAt(book, sheet, at.r, at.c); - const has = sheet.칸[toA1(at.r, at.c)]; - const shown = - has && (has.값 !== undefined || has.식 !== undefined) - ? formatValue(engine.value(sheet.id, at.r, at.c), style.형식) - : { 글: "", 정렬: "left" as const }; - const key = `${sheet.id}!${toA1(r, c)}`; - 값[key] = shown.글; - const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); - return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; - }; - const cells = rows.map((r) => - cols.map((c): TableCell | null => { - const merge = mergeAt(r, c); - if (!merge) return cellOf(r, c, { r, c }); - const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1); - const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1); - if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리 - const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸 - return { - ...cell, - ...(inCols.length > 1 ? { colSpan: inCols.length } : {}), - ...(inRows.length > 1 ? { rowSpan: inRows.length } : {}), - }; - }), - ); - const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS; - const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT; - const 표 = { - type: "Table", - lineWidth: 1, - shapeData: { - origin: { x: 0, y: 0 }, - columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K), - rowHeights: rows.map( - (r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K, - ), - cells, - style: { fontSize: font, padding: 1, 연결: "" }, - }, - }; - return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 }; -} - /** 이름정의를 더하거나(있으면) 범위만 바꿈 — 책 사본을 돌려줌(저장은 python 쪽) */ function defineAreaName(ask: NameAsk): { book: Workbook } { const book = ask.book; diff --git a/resources/tester/test_m02_area_live.py b/resources/tester/test_m02_area_live.py new file mode 100644 index 000000000..9f0115455 --- /dev/null +++ b/resources/tester/test_m02_area_live.py @@ -0,0 +1,224 @@ +"""면적 상세 오른쪽 도면 패널 — 저장 전 실시간 표 값(PLAN 112-1) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 브라우저 `areaTable`(`spreadsheet_area_table.ts`)이 서버 Node 번들 `area_tables` 와 같은 표 · 값 · 까닭을 냄 + (한 벌의 코드 — 서버 파일이 그 파일을 가져다 씀). + - `AreaDrawing.sync` — 패널이 펼쳐진 동안만 · 값이 바뀐 연결 표만 · CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 · + 이름이 지워진 표는 건너뜀 · 보내기 취소한 연결은 뺌 · 접었다 펴면 따라잡음 · 도면 문서는 안 건드림. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from common_util import common_util_spreadsheet as sheet_node + +ROOT = Path(__file__).resolve().parents[2] + +SHEET = { + "id": "s1", + "이름": "면적산출", + "칸": { + "A1": {"값": "제목", "서식": 2}, + "B1": {"값": 1234.5, "서식": 1}, + "A2": {"값": "합"}, + "B2": {"식": "B1*2", "서식": 1}, + "A3": {"값": "숨김행"}, + "C1": {"값": "x"}, + }, + "병합": ["A1:A2", "C1:E5"], + "행": {"3": {"숨김": True}}, + "열": {"D": {"숨김": True}, "B": {"폭": 12}}, + "이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}], +} +BOOK = { + "종류": "통합문서", + "판": 1, + "열": "rv", + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "시트": [SHEET], + "활성": "s1", +} +ASKS = [ + {"시트": "면적산출", "범위": "A1:B2"}, + {"시트": "s1", "범위": "A1:D4", "글자크기": 5}, + {"시트": "s1", "범위": "", "이름": "도면_T1"}, + {"시트": "s1", "범위": "A1", "이름": "도면_없음"}, + {"시트": "없음", "범위": "A1"}, + {"시트": "s1", "범위": "가나"}, + {"시트": "s1", "범위": "A3"}, +] + +LOADER = r""" +const fs = require("fs"); +const path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const cache = new Map(); +function loadTs(file, stubs = {}) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full).exports; + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const mod = { exports: {} }; + cache.set(full, mod); + const req = (r) => { + if (r in stubs) return stubs[r]; + if (r.endsWith(".css")) return {}; + if (r.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${r.slice(4)}.ts`)); + if (r.startsWith(".")) return loadTs(path.join(path.dirname(full), `${r}.ts`)); + return require(r); + }; + new Function("exports", "module", "require", js)(mod.exports, mod, req); + return mod.exports; +} +const SS = (name) => path.join(ROOT, "A00_Common", "spreadsheet", `${name}.ts`); +""" + +TABLE_SCRIPT = ( + LOADER + + r""" +const { areaTable } = loadTs(SS("spreadsheet_area_table")); +const { book, asks } = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const out = asks.map((ask) => { + try { + return { ok: true, ...areaTable({ book: structuredClone(book), ...ask }) }; + } catch (e) { + return { ok: false, 까닭: e.message }; + } +}); +console.log(JSON.stringify(out)); +""" +) + +LIVE_SCRIPT = ( + LOADER + + r""" +const base = JSON.parse(process.argv[2]); +const book = (cells = {}) => ({ ...base, 시트: [{ ...base.시트[0], 칸: { ...base.시트[0].칸, ...cells } }] }); +const rows = [ + { 연결: "T1", 시트: "s1", 범위: "A1:B2", 이름: "도면_T1", 글자크기: 2.5, 값: {} }, + { 연결: "T2", 시트: "s1", 범위: "A1:A1", 이름: "도면_없음", 글자크기: 2.5, 값: {} }, + { 연결: "T3", 시트: "s1", 범위: "B1:B1", 글자크기: 2.5, 값: {} }, +]; +const same = (a = {}, b = {}) => JSON.stringify(a) === JSON.stringify(b); +const calls = []; +const handle = { + updateTable: async (link, cells, preview) => (calls.push({ link, cells, preview }), 1), + destroy() {}, +}; +const fake = (tag, o = {}) => ({ tag, dataset: {}, ...o, append() {}, replaceChildren() {}, querySelector: () => null, remove() {}, addEventListener() {} }); +globalThis.window = { addEventListener() {}, removeEventListener() {}, location: { origin: "" }, setTimeout, clearTimeout }; +const { createAreaDrawing } = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_AreaDrawing.ts"), { + "@ui/ui_template_elements": { createButton: (o) => fake("button", o), el: fake }, + "./M02_MasterTemplete_Api_Fetch": { readTemplate: async () => ({ 문서: { 도면: { entities: [] }, 연결표: rows } }) }, + "./M02_MasterTemplete_Drawing": { mountStructureSheets: () => handle }, + "./M02_MasterTemplete_AreaToDrawing": { sameValues: same }, +}); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const take = () => calls.splice(0).map((c) => [c.link, c.preview, c.cells.at(-1)[0]?.text ?? null]); + +(async () => { + const cancelled = new Set(); + const ad = createAreaDrawing("system", "rv", { cancelled: () => cancelled }); + const log = {}; + ad.sync(() => book()); + await wait(400); + log.closed = take(); // 패널이 한 번도 안 열림 — 건너뜀 + ad.show(); + await wait(500); + log.opened = take(); // 열리면 따라잡음 — 이름이 지워진 T2 는 건너뜀 + ad.sync(() => book()); + await wait(400); + log.same = take(); // 같은 값 — 도면에 안 보냄 + ad.sync(() => book({ B1: { 값: 10, 서식: 1 } })); + await wait(400); + log.edited = take(); // 값이 바뀐 표만 + ad.hide(); + ad.sync(() => book({ B1: { 값: 20, 서식: 1 } })); + await wait(400); + log.hidden = take(); // 접힌 동안은 건너뜀 + ad.show(); + await wait(400); + log.again = take(); // 다시 펴면 따라잡음 + cancelled.add("T1"); + ad.sync(() => book({ B1: { 값: 30, 서식: 1 } })); + await wait(400); + log.cancelled = take(); // 보내기 취소한 연결은 뺌 + ad.destroy(); + console.log(JSON.stringify(log)); +})(); +""" +) + + +@pytest.fixture(scope="module") +def node() -> str: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + return "node" + + +def test_브라우저_표_변환은_서버_번들과_같은_표_값_까닭(node, tmp_path): + src = tmp_path / "ask.json" + src.write_text(json.dumps({"book": BOOK, "asks": ASKS}, ensure_ascii=False), encoding="utf-8") + proc = subprocess.run( + [node, "-e", TABLE_SCRIPT, str(ROOT), str(src)], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + browser = json.loads(proc.stdout.strip().splitlines()[-1]) + server = sheet_node.area_tables([{"book": BOOK, **ask} for ask in ASKS]) + assert browser == server + assert [x["ok"] for x in browser] == [True, True, True, False, False, False, False] + assert browser[2]["범위"] == "B1:C2" # 이름정의가 가리키는 지금 범위 + + +def test_서버_번들은_그_파일을_가져다_씀(): + src = (ROOT / "common_util/common_util_spreadsheet_node.ts").read_text("utf-8") + assert 'from "../A00_Common/spreadsheet/spreadsheet_area_table"' in src + assert "function areaTable" not in src # 두 벌로 갈라지지 않음 + + +@pytest.fixture(scope="module") +def live(node): + proc = subprocess.run( + [node, "-e", LIVE_SCRIPT, str(ROOT), json.dumps(BOOK, ensure_ascii=False)], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_패널이_닫혀_있으면_건너뜀(live): + assert live["closed"] == [] + + +def test_열리면_따라잡음_보기_전용_갈음_이름_지워진_표는_건너뜀(live): + # [연결, preview, 마지막 줄 첫 칸 글] — T1 = B1:C2 이름 범위 · T3 = B1 한 칸 + assert [x[:2] for x in live["opened"]] == [["T1", True], ["T3", True]] + assert live["opened"][0][2] == "2,469.00" + + +def test_같은_값이면_안_보내고_바뀐_표만_보냄(live): + assert live["same"] == [] + assert [x[0] for x in live["edited"]] == ["T1", "T3"] # B1 이 T1 · T3 범위 안 + assert live["edited"][0][2] == "20.00" # 식 칸 B2 = B1*2 + + +def test_접은_동안은_건너뛰고_다시_펴면_따라잡음(live): + assert live["hidden"] == [] + assert [x[0] for x in live["again"]] == ["T1", "T3"] + assert live["again"][0][2] == "40.00" + + +def test_보내기_취소한_연결은_뺌(live): + assert [x[0] for x in live["cancelled"]] == ["T3"] diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 63bf88099..84412ebce 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -182,17 +182,26 @@ def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없 assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록 -NAMED_SHEET = {**SHEET, "이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}]} +NAMED_SHEET = { + **SHEET, + "이름정의": [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}], +} NAMED_BOOK = {**BOOK, "시트": [NAMED_SHEET]} def test_이름으로_범위를_풂_104_1(): - got = sheet_node.area_tables([{"book": NAMED_BOOK, "시트": "s1", "범위": "", "이름": "도면_T1"}])[0] - assert got["ok"] and got["범위"] == "B1:C2" # 이름정의가 가리키는 지금 범위로(저장된 범위는 안 봄) + got = sheet_node.area_tables( + [{"book": NAMED_BOOK, "시트": "s1", "범위": "", "이름": "도면_T1"}] + )[0] + assert ( + got["ok"] and got["범위"] == "B1:C2" + ) # 이름정의가 가리키는 지금 범위로(저장된 범위는 안 봄) def test_이름이_지워졌으면_이름없음_104_1(): - got = sheet_node.area_tables([{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_없음"}])[0] + got = sheet_node.area_tables( + [{"book": BOOK, "시트": "s1", "범위": "B1:C2", "이름": "도면_없음"}] + )[0] assert not got["ok"] and got["까닭"] == area_table.NAME_GONE @@ -207,7 +216,9 @@ def test_define_names_있으면_갱신_없으면_더함_104_1(): [{"book": added["book"], "시트": "s1", "범위": "A1:A1", "이름": "도면_T9"}] )[0] kept = [n for n in again["book"]["시트"][0]["이름정의"] if n["이름"] == "도면_T9"] - assert kept == [{"이름": "도면_T9", "범위": {"r0": 0, "c0": 0, "r1": 0, "c1": 0}}] # 갱신(둘 아님) + assert kept == [ + {"이름": "도면_T9", "범위": {"r0": 0, "c0": 0, "r1": 0, "c1": 0}} + ] # 갱신(둘 아님) def _named_link_drawing(folder: Path) -> None: @@ -225,7 +236,9 @@ def test_저장_때_이름이_가리키는_범위로_갱신_104_1(world): with store.at(system): got = store.read("area", "rv") doc = got["문서"] - doc["시트"][0]["이름정의"] = [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}}] + doc["시트"][0]["이름정의"] = [ + {"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} + ] store.write("area", "rv", got["판"], doc) drawing = store.read("structure", "rv")["문서"] assert drawing["연결표"][0]["범위"] == "B1:C2" # 옛(A1:B2) 아님 — 이름이 가리키는 지금 범위 @@ -236,35 +249,108 @@ def test_이름_통째_지워지면_연결_끊고_지운연결_104_1(world): _named_link_drawing(system) # 이름정의 없는 책 — 이름이 없는 것과 같음 with store.at(system): got = store.read("area", "rv") + got["문서"]["시트"][0]["이름정의"] = [] # 면적에서 이름을 지움 store.write("area", "rv", got["판"], got["문서"]) # 면적 저장 → refresh_drawing drawing = store.read("structure", "rv")["문서"] assert drawing["연결표"] == [] # 연결 끊김(도면 표 엔티티는 안 지움) assert drawing["지운연결"] == ["T1"] -def test_이름_정의_엔드포인트_104_1(world): - world["session"]["role"] = "SYSTEM_ADMIN" # system 층 쓰기 권한 - client = world["client"] - res = client.post( - "/api/m02/layers/system/area/rv/name", - json={"시트": "s1", "범위": "B1:C2", "이름": "도면_T5"}, - ) - assert res.status_code == 200, res.text - with store.at(world["system"]): - names = store.read("area", "rv")["문서"]["시트"][0]["이름정의"] - assert {"이름": "도면_T5", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} in names +def _area_names(folder: Path) -> list[dict]: + doc = json.loads((folder / "area" / "rv.json").read_bytes()) + return doc["시트"][0].get("이름정의") or [] -def test_보내기_취소_엔드포인트_104_3(world): - world["session"]["role"] = "SYSTEM_ADMIN" # system 층 쓰기 권한 +def _save_links(rows: list[dict], version: str | None = None, entities: list | None = None) -> None: + got = store.read("structure", "rv") + doc = got["문서"] + doc["연결표"] = rows + if entities is not None: + doc["도면"]["entities"] = entities + store.write("structure", "rv", got["판"] if version is None else version, doc) + + +T1_ROW = {"연결": "T1", "시트": "s1", "범위": "B1:C2", "이름": "도면_T1", "글자크기": 2.5, "값": {}} +T1_NAME = {"이름": "도면_T1", "범위": {"r0": 0, "c0": 1, "r1": 1, "c1": 2}} + + +def test_도면_저장_때_새_연결_줄_이름이_같은_요청_안에서_면적_문서에_생김_112_2(world): system = world["system"] - _named_link_drawing(system) - client = world["client"] - res = client.post("/api/m02/layers/system/structures/rv/unlink", json={"연결": "T1"}) - assert res.status_code == 200, res.text + before = (system / "area" / "rv.json").read_bytes() with store.at(system): - doc = store.read("structure", "rv")["문서"] - assert doc["연결표"] == [] # 도면 엔티티는 이 길에서 안 건드림(지우기는 도면에서) + assert (system / "area" / "rv.json").read_bytes() == before # 저장 전 = 서버 면적 그대로 + _save_links([T1_ROW]) + assert T1_NAME in _area_names(system) # 저장 뒤 이름 + # 면적에서 이름을 지운 뒤 같은 줄로 다시 저장 — 새 줄만 이름을 둠(도로 만들지 않음) + path = system / "area" / "rv.json" + book = json.loads(path.read_bytes()) + book["시트"][0]["이름정의"] = [] + path.write_text(json.dumps(book, ensure_ascii=False), encoding="utf-8") + _save_links([T1_ROW, {**T1_ROW, "연결": "T2", "이름": "도면_T2"}]) + names = [n["이름"] for n in _area_names(system)] + assert names == ["도면_T2"] + + +def test_판_충돌로_도면_저장이_막히면_이름도_안_생김_112_2(world): + system = world["system"] + before = (system / "area" / "rv.json").read_bytes() + with store.at(system), pytest.raises(store.StoreError) as stale: + _save_links([T1_ROW], version="낡은판") + assert stale.value.status == 409 + assert (system / "area" / "rv.json").read_bytes() == before + + +def test_이름_없는_옛_연결_줄은_이름을_안_둠_112_2(world): + system = world["system"] + with store.at(system): + _save_links([{"연결": "T1", "시트": "s1", "범위": "A1:B2", "값": {}}]) + assert _area_names(system) == [] + + +def test_면적_저장_보내기취소_줄과_이름을_뺌_도면_표는_남음_112_2(world): + system = world["system"] + t2 = {**T1_ROW, "연결": "T2", "이름": "도면_T2"} + old = {"연결": "T3", "시트": "s1", "범위": "A1:A1", "값": {}} # 이름 없는 옛 연결 + with store.at(system): + table = { + "type": "Table", + "shapeData": {"cells": [[{"text": "x"}]], "style": {"연결": "T1"}}, + } + _save_links([T1_ROW, t2, old], entities=[table]) # 도면 표(T1) 있음 + got = store.read("area", "rv") + # 저장 전 — 서버 도면 연결표는 그대로(취소는 화면에서만) + assert [x["연결"] for x in store.read("structure", "rv")["문서"]["연결표"]] == [ + "T1", + "T2", + "T3", + ] + doc = {**got["문서"], "보내기취소": ["T1", "T3"]} + store.write("area", "rv", got["판"], doc) + drawing = store.read("structure", "rv")["문서"] + area = store.read("area", "rv")["문서"] + assert [x["연결"] for x in drawing["연결표"]] == [ + "T2" + ] # T1 · T3 줄 빠짐(남은 줄은 지금 범위로) + assert "지운연결" not in drawing # 취소는 「범위가 지워져」 알림이 아님 + assert drawing["도면"]["entities"] # 도면 표는 안 지움 + assert [n["이름"] for n in area["시트"][0]["이름정의"]] == ["도면_T2"] # T1 이름 뺌 + assert "보내기취소" not in area # 임시 칸은 저장 안 함 + + +def test_프로젝트_층_저장도_보내기취소를_뗌_112_2(world, tmp_path): + import shutil + + project = tmp_path / "project" + shutil.copytree(world["system"], project) + with store.at(project): + _save_links([T1_ROW]) + book = json.loads((project / "area" / "rv.json").read_bytes()) + book["시트"][0]["이름정의"] = [T1_NAME] + book["보내기취소"] = ["T1"] + cut, ids = layers_router._apply_cancels(project, "rv", book) + assert ids == ["T1"] and "보내기취소" not in cut and cut["시트"][0]["이름정의"] == [] + layers_router._refresh_drawing(project, "rv", ids) + assert json.loads((project / "structure" / "rv.json").read_bytes())["연결표"] == [] def test_프로젝트_층_길도_같은_맞춤(world, tmp_path): diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index 2b1003213..9f1cc4900 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -50,11 +50,17 @@ const stubs = { return res; }, }, - "./M02_MasterTemplete_Api_Fetch": { getActiveOwner: () => null }, + "./M02_MasterTemplete_Api_Fetch": { + getActiveOwner: () => null, + readTemplate: async () => ({ 문서: { 연결표: [ + { 연결: "T1", 시트: "s1", 범위: "A1:A1", 이름: "도면_T1", 글자크기: 3, 값: {} }, + { 연결: "T2", 시트: "s1", 범위: "B1:B1", 글자크기: 3, 값: {} }, + ] } }), + }, }; const box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); -const { createAreaToDrawing } = box.exports; +const { createAreaToDrawing, sentRanges } = box.exports; const table = (texts, style = {}) => ({ type: "Table", @@ -138,7 +144,21 @@ globalThis.fetch = async (url, init) => { await slot.kids[0].on.click(); await new Promise((r) => setTimeout(r, 20)); const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) }; - console.log(JSON.stringify({ esc, ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty })); + // 112-2 — 이름은 도면 [저장] 요청 안에서 서버가 두니 놓을 때 서버를 안 부름(표 변환 길만) + const urls = calls.map((c) => c[0]); + // 112-2 — [보내기 취소]: 화면에서만(서버 안 부름) · 저장 안 한 고침 · 면적 저장 문서에 보내기취소[] · 저장 뒤 비움 + const callsBefore = calls.length; + const marks = await sentRanges(area, (id) => ad.cancel(id)); + const cancelOk = await marks[0].취소(); + const cancelWaiting = ad.waiting(); + const cancelSet = [...ad.cancelled()]; + const withKey = ad.withCancels({ 열: "item1" }); + const second = ad.withCancels({ 열: "item1", 보내기취소: ["옛"] }); // 앞 저장 것은 떼고 다시 담음 + ad.cancel("T2"); + const both = ad.withCancels({ 열: "item1" }).보내기취소; + ad.drop(); + const cancel = { ok: cancelOk, serverCalls: calls.length - callsBefore, waiting: cancelWaiting, set: cancelSet, withKey, second, both, after: ad.waiting(), none: ad.withCancels({ 열: "item1", 보내기취소: ["옛"] }), labels: marks.map((m) => [m.이름, m.범위이름 ?? null, !!m.취소]) }; + console.log(JSON.stringify({ urls, cancel, esc, ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty })); })(); """ @@ -203,10 +223,30 @@ def test_다른_열_도면에는_안_놓음(got): assert got["other"] == 0 +def test_이름은_놓을_때_서버에_안_씀_도면_저장_때_서버가_둠_112_2(got): + assert all(url.endswith("/table") for url in got["urls"]) # …/name 을 부르는 길이 없음 + src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts").read_text("utf-8") + assert "defineAreaName" not in src and "/unlink" not in src and "/name" not in src + assert got["saved"][1]["이름"] == "도면_T2" # 새 줄 이름은 저장 문서 연결표에 실려 감 + + +def test_보내기_취소는_화면에서만_저장_안_한_고침_저장에_실려_감_112_2(got): + cancel = got["cancel"] + assert cancel["labels"] == [["T1 도면", "도면_T1", True], ["T2 도면", None, True]] + assert cancel["ok"] is True and cancel["serverCalls"] == 0 # 서버 안 부름 + assert cancel["waiting"] is True and cancel["set"] == ["T1"] # 면적 화면 「저장 안 한 고침」 + assert cancel["withKey"] == {"열": "item1", "보내기취소": ["T1"]} + assert cancel["second"] == {"열": "item1", "보내기취소": ["T1"]} # 앞 저장 칸은 떼고 다시 + assert cancel["both"] == ["T1", "T2"] + assert cancel["after"] is False # 저장 · 떠남(drop) 뒤 비움 + assert cancel["none"] == {"열": "item1"} + + def test_편집_칸은_갈고리만(): main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - # select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인) · cancelPlace(Esc) - assert main.count("toDrawing.") == 7 + # select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인 · 저장 끝) ×2 · + # cancelPlace(Esc) · cancel · cancelled · withCancels + assert main.count("toDrawing.") == 11 assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0] diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index dba23860c..2931d1e82 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -104,7 +104,7 @@ export interface CadHost { * 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */ placeTable: (entity: Record, link: string) => void; /** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */ - updateTable: (link: string, cells: unknown[][]) => Promise; + updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } @@ -201,12 +201,17 @@ export function createCadHost["placeTable"] = (entity, link) => postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link }); - const updateTable: CadHost["updateTable"] = (link, cells) => + const updateTable: CadHost["updateTable"] = (link, cells, preview) => new Promise((resolve, reject) => { const waits = tableWaits.get(link) ?? []; waits.push(resolve); tableWaits.set(link, waits); - postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells }); + postAfterLoad({ + type: CAD_UPDATE_TABLE_MESSAGE, + link, + cells, + ...(preview ? { preview: true } : {}), + }); window.setTimeout(() => { const left = tableWaits.get(link) ?? []; if (!left.includes(resolve)) return;