From 4a264d961e1b30762aef1d7d2c8745ff6aa02a7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:47:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EB=8F=84=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EB=82=B4=EA=B8=B0=20=E2=80=94=2041-1=20?= =?UTF-8?q?=ED=91=9C=20=EB=B3=80=ED=99=98=20=EB=8B=B5=20=C2=B7=2041-2=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=EA=BC=B4?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4(41-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 답 {표, 시트, 범위, 값} · 연결표 줄 = {연결, 시트, 범위, 글자크기, 값(시트!A1: 글)} · 다름 = 값 견줌 - placeTable(표, 연결) · [업데이트] = updateTable 바꾼 표만 값 갱신 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Api_Fetch.ts | 10 +- .../M02_MasterTemplete_AreaToDrawing.ts | 94 +++++++++++-------- resources/tester/test_m02_area_to_drawing.py | 22 +++-- 3 files changed, 74 insertions(+), 52 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index c3d2e182e..03b7a9d51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -135,8 +135,14 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; - /** 면적산출 → 도면 표 연결 기록(41-3 · `AreaToDrawing`) — 표 style.연결 = id · 값 = 마지막 칸 글 */ - 연결표?: { id: string; 시트: string; 범위: string; 글자크기: number; 값: (string | null)[][] }[]; + /** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */ + 연결표?: { + 연결: string; + 시트: string; + 범위: string; + 글자크기: number; + 값: Record; + }[]; } /** 시스템 층 구조물 도면 목록 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index bc2f21bf5..3f8878431 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -4,8 +4,8 @@ * ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5) * → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열 * 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2) - * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {id(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글)} · 표 style.연결 = id · - * 도면에서 지운 표는 기록에서 뺌 + * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} · + * 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) * ④ 표 여러 개 — id 마다 한 줄 * ========================================================================== */ @@ -36,14 +36,21 @@ interface TableEntityJson { >; [key: string]: unknown; } -/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막으로 도면에 적힌 칸 글 · 다름 알림 기준) */ +/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */ export type TableLink = NonNullable[number]; -type LinkSpec = Omit; +type LinkSpec = Pick; +/** 표 변환 길 답(41-1) — 시트 = 시트 id */ +interface AreaTable { + 표: TableEntityJson; + 시트: string; + 범위: string; + 값: Record; +} -/** 도면 손잡이에 더해지는 표 함수(41-2) — 없으면 단추 · 알림이 조용히 빠짐 */ +/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */ interface TableHandle { - placeTable?: (entity: TableEntityJson) => void; - updateTable?: (link: string, cells: TableEntityJson["shapeData"]["cells"]) => void; + placeTable?: (entity: Record, link: string) => void; + updateTable?: (link: string, cells: unknown[][]) => Promise; } const TEXT = { @@ -73,25 +80,22 @@ const fill = (text: string, key: string, value: string | number): string => const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const enc = encodeURIComponent; -/** 범위 → 표 객체(41-1 서버 · 저장 안 함) — 지금 보는 층 · 주인 */ -async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise { +/** 범위 → 표 객체 · 칸 글(41-1 서버 · 저장본 면적 · 저장 안 함) — 지금 보는 층 · 주인 */ +async function fetchTable(layer: Layer, column: string, spec: LinkSpec): 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)}/table${q}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(spec), + body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }), }); - const data = (await res.json().catch(() => ({}))) as { - detail?: unknown; - entity?: TableEntityJson; - } & Partial; - if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial; + if (!res.ok || !data.표) { const d = data.detail; throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`); } - return (data.entity ?? data) as TableEntityJson; + return data as AreaTable; } const isTable = (e: unknown): e is TableEntityJson => @@ -100,10 +104,11 @@ const linkOf = (e: TableEntityJson): string | null => { const id = e.shapeData?.style?.[LINK]; return typeof id === "string" && id ? id : null; }; -const textsOf = (cells: TableEntityJson["shapeData"]["cells"]): (string | null)[][] => - (cells ?? []).map((row) => (row ?? []).map((c) => (c ? String(c.text ?? "") : null))); -const sameTexts = (a: (string | null)[][], b: (string | null)[][]): boolean => - JSON.stringify(a) === JSON.stringify(b); +/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */ +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]); +}; export interface AreaToDrawingOptions { /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ @@ -130,10 +135,10 @@ export interface AreaToDrawing { export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { let range: { 시트: string; 범위: string } | null = null; /** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */ - let pending: { layer: Layer; column: string; entity: TableEntityJson; spec: LinkSpec } | null = - null; - /** 이 도면에서 새로 놓은 표의 연결(저장 때 기록) · 열어 둔 도면 */ - let fresh = new Map(); + let pending: { layer: Layer; column: string; got: AreaTable; 글자크기: number } | null = null; + /** 이 도면에서 새로 놓은 표 · [업데이트]로 바뀐 값(저장 때 기록) · 열어 둔 도면 */ + let fresh = new Map(); + let latest = new Map>(); let shown: string | null = null; /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ @@ -180,13 +185,13 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const size = await askSize(picked); if (size === null) return; const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; - let entity: TableEntityJson; + let got: AreaTable; try { - entity = await fetchTable(at.layer, at.name, spec); + got = await fetchTable(at.layer, at.name, spec); } catch (error) { return showToast(fill(TEXT.failed, "value", why(error)), "error"); } - pending = { layer: at.layer, column: at.name, entity, spec }; + pending = { layer: at.layer, column: at.name, got, 글자크기: size }; await opt.open({ layer: at.layer, projectId: null, @@ -198,7 +203,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { /** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */ const nextId = (doc: StructureDoc, taken: Set): string => { - for (const row of doc.연결표 ?? []) taken.add(row.id); + for (const row of doc.연결표 ?? []) taken.add(row.연결); for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? ""); let n = 1; while (taken.has(`T${n}`)) n += 1; @@ -220,15 +225,21 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { ); if (shown !== key) return; // 그 사이 다른 화면 const stale = got.filter( - (x): x is { row: TableLink; e: TableEntityJson } => - !!x && !sameTexts(x.row.값 ?? [], textsOf(x.e.shapeData.cells)), + (x): x is { row: TableLink; e: AreaTable } => !!x && !sameValues(x.row.값, x.e.값), ); if (!stale.length) return; const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" }); - update.addEventListener("click", () => { - for (const { row, e } of stale) handle.updateTable?.(row.id, e.shapeData.cells); + update.addEventListener("click", async () => { + update.disabled = true; + let n = 0; + for (const { row, e } of stale) { + const changed = (await handle.updateTable?.(row.연결, e.표.shapeData.cells)) ?? 0; + if (changed) latest.set(row.연결, e.값); + n += changed ? 1 : 0; + } + if (shown !== key) return; opt.showNotice([]); - showToast(fill(TEXT.updated, "n", stale.length), "success"); + showToast(fill(TEXT.updated, "n", n), "success"); }); opt.showNotice([fill(TEXT.stale, "n", stale.length), update]); } @@ -248,6 +259,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; fresh = new Map(); + latest = new Map(); const give = pending && pending.layer === at.layer && pending.column === at.name ? pending : null; pending = null; @@ -255,24 +267,24 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { if (!handle.placeTable) showToast(TEXT.noPlace, "error"); else { const id = nextId(doc, new Set()); - const style = { ...(give.entity.shapeData.style ?? {}), [LINK]: id }; - fresh.set(id, give.spec); - handle.placeTable({ ...give.entity, shapeData: { ...give.entity.shapeData, style } }); + const { 표, 시트, 범위, 값 } = give.got; + const style = { ...(표.shapeData.style ?? {}), [LINK]: id }; + fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 }); + handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id); showToast(TEXT.place, "info"); } } void checkStale(handle, at, doc); }, withLinks: (doc) => { - const old = new Map((doc.연결표 ?? []).map((row) => [row.id, row])); + const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row])); const rows: TableLink[] = []; for (const e of doc.도면?.entities ?? []) { if (!isTable(e)) continue; const id = linkOf(e); - const spec = id ? (old.get(id) ?? fresh.get(id)) : undefined; - if (!id || !spec || rows.some((r) => r.id === id)) continue; - const { 시트, 범위, 글자크기 } = spec; - rows.push({ id, 시트, 범위, 글자크기, 값: textsOf(e.shapeData.cells) }); + const row = id ? (old.get(id) ?? fresh.get(id)) : undefined; + if (!id || !row || rows.some((r) => r.연결 === id)) continue; + rows.push({ ...row, 값: latest.get(id) ?? row.값 }); } // 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로) return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc; diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index f7fb6c79d..b0cf649be 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -64,7 +64,8 @@ let now = [["1.5", "2"]]; const calls = []; globalThis.fetch = async (url, init) => { calls.push([url, JSON.parse(init.body)]); - return { ok: true, json: async () => table(now) }; + const b = JSON.parse(init.body); + return { ok: true, json: async () => ({ 표: table(now), 시트: "s1", 범위: b.범위, 값: { "s1!B2": now[0][0], "s1!C2": now[0][1] } }) }; }; (async () => { @@ -89,17 +90,18 @@ globalThis.fetch = async (url, init) => { // 같은 열 도면 — 기록 T1 이 있으니 새 표는 T2 const placed = []; - const handle = { placeTable: (e) => placed.push(e), updateTable: (id, c) => placed.push(["u", id, c]) }; - const old = { id: "T1", 시트: "산출", 범위: "A1:A1", 글자크기: 3, 값: [["옛"]] }; + const handle = { placeTable: (e, id) => placed.push([e, id]), updateTable: async (id, c) => (placed.push(["u", id, c]), 1) }; + const old = { 연결: "T1", 시트: "s1", 범위: "A1:A1", 글자크기: 3, 값: { "s1!A1": "옛" } }; const drawing = (ents, links) => ({ 양식: "구조물도면", 종류: "structure", 판: 1, 열: "item1", 도번: "SD-1", 도면: { entities: ents }, ...(links ? { 연결표: links } : {}) }); ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([table([["옛"]], { 연결: "T1" })], [old])); await new Promise((r) => setTimeout(r, 20)); const notice = notices.at(-1) ?? []; const upd = notice.find((x) => x && x.on); - if (upd) upd.on.click(); + if (upd) await upd.on.click(); - // 저장 — T1 은 지움 · T2 만 남음 · 값 = 표 칸 글 - const saved = ad.withLinks(drawing([{ type: "Line" }, table([["9", "8"]], { 연결: "T2" })], [old])); + // 저장 — T1(업데이트로 값 바뀜) · T2 · 지운 T3 기록은 빠짐 + const t3 = { ...old, 연결: "T3" }; + const saved = ad.withLinks(drawing([{ type: "Line" }, table([["옛"]], { 연결: "T1" }), table([["9", "8"]], { 연결: "T2" })], [old, t3])); const plain = ad.withLinks(drawing([table([["x"]])])); // 다른 열 도면이면 안 놓음 const before = placed.length; @@ -136,8 +138,8 @@ def test_보냄은_표_변환_길_뒤_같은_열_도면을_엶(got): def test_놓을_표는_겹치지_않는_연결_id(got): - first = got["placed"][0] - assert first["shapeData"]["style"]["연결"] == "T2" + entity, link = got["placed"][0] + assert link == "T2" and entity["shapeData"]["style"]["연결"] == "T2" def test_다시_열면_다름_알림과_업데이트(got): @@ -147,8 +149,10 @@ def test_다시_열면_다름_알림과_업데이트(got): def test_저장_연결표는_남은_표만(got): + now = {"s1!B2": "1.5", "s1!C2": "2"} assert got["saved"] == [ - {"id": "T2", "시트": "산출", "범위": "B2:C2", "글자크기": 2.5, "값": [["9", "8"]]} + {"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값 + {"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now}, ] assert got["plain"] is False