From c426fa2ad6a62d5882d08b668094fb5778de9e53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:43:40 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B07=20CAD):=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20=C2=B7=20=ED=91=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20place-table=20=C2=B7=20update-table=20(41-?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 tools/annotate/place-table-tool.ts — PLACETABLE: 부모가 보낸 표를 커서에 붙여 미리보기 · 누르면 그 자리(왼쪽 위) · Esc 그만 · 놓기 = 되돌리기 한 걸음 - 새 integration/aislo-table-bridge.ts — place-table{entity, link} = style.연결 단 표로 놓기 시작 · update-table{link, cells} = 연결 같은 표 칸만 갈기(자리 · 크기 그대로) → table-updated{link, count} · 확정 · 보기 전용이면 안 함 - TableEntity style.연결 · replaceCells · bridge 갈래 한 줄 · 명령 PLACETABLE - 시험 aislo-table-bridge.test.ts(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../src/commands/commands.annotate.ts | 9 ++ .../openwebcad/src/entities/TableEntity.ts | 7 ++ .../src/integration/aislo-drawing-bridge.ts | 2 + .../integration/aislo-table-bridge.test.ts | 114 ++++++++++++++++++ .../src/integration/aislo-table-bridge.ts | 80 ++++++++++++ B07_DesignDetail/openwebcad/src/tools.ts | 1 + .../src/tools/annotate/place-table-tool.ts | 49 ++++++++ 7 files changed, 262 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index c738faebc..e2583de55 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -29,6 +29,7 @@ import { revCloudToolStateMachine, updateDimensions, } from '../tools/annotate/leader-table-tools'; +import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool'; import { tableColumnToolStateMachine, tableEditToolStateMachine, @@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [ tool: Tool.TABLEUNMERGE, machine: tableUnmergeToolStateMachine, }, + { + id: 'PLACETABLE', + label: '표 놓기', + glyph: '▦', + hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다', + tool: Tool.PLACETABLE, + machine: placeTableToolStateMachine, + }, ]; export const MARKUP_COMMANDS: CadCommand[] = [ diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 93e5ec55a..5662184dd 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -34,6 +34,8 @@ export interface TableStyleOptions { textColor: string; /** 문자를 칸 좌우 끝에서 띄우는 거리 */ padding: number; + /** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */ + 연결?: string; } export const DEFAULT_TABLE_STYLE: TableStyleOptions = { @@ -97,6 +99,11 @@ export class TableEntity implements Entity { } // ── 쓰기 (표 편집 명령이 쓴다) + /** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */ + public replaceCells(cells: TableCells): void { + this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length); + } + public setCell(row: number, column: number, patch: Partial): void { const current = this.cells[row]?.[column]; if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다 diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 29f4bea06..47ecd4f5b 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -33,6 +33,7 @@ import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; +import { handleTableMessage } from './aislo-table-bridge.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -190,6 +191,7 @@ function applyFramePreview(): void { export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; + if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장) const message = event.data as Partial; if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { try { diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts new file mode 100644 index 000000000..4057df8d7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -0,0 +1,114 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TableEntity } from '../entities/TableEntity.ts'; +import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts'; +import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts'; +import { + AISLO_PLACE_TABLE_MESSAGE, + AISLO_TABLE_UPDATED_MESSAGE, + AISLO_UPDATE_TABLE_MESSAGE, + handleTableMessage, + updateLinkedTables, +} from './aislo-table-bridge.ts'; + +const run = vi.hoisted(() => vi.fn()); +vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run })); + +const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' }; + +function table(link?: string): TableEntity { + return new TableEntity( + layer.id, + new Point(10, 20), + [30, 15], + [5, 5], + [ + [{ text: '항목', colSpan: 2, key: 'S1!A1' }, null], + [ + { text: '면적', key: 'S1!A2' }, + { text: '12.5', align: 'right', key: 'S1!B2' }, + ], + ], + { fontSize: 2.5, ...(link ? { 연결: link } : {}) } + ); +} + +beforeEach(() => { + setLayers([layer]); + setEntities([]); + setPendingTable(null); + run.mockClear(); +}); + +describe('표 놓기(PLACETABLE)', () => { + it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => { + const placed = tableAt(table('T1'), new Point(100, 200), layer.id); + expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]); + expect(placed.getColumnWidths()).toEqual([30, 15]); + expect(placed.getCell(1, 1)?.text).toBe('12.5'); + expect(placed.getStyle().연결).toBe('T1'); + }); + + it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => { + const notify = vi.fn(); + const handled = await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T7' }, + notify + ); + expect(handled).toBe(true); + expect(run).toHaveBeenCalledWith('PLACETABLE'); + expect(getPendingTable()?.getStyle().연결).toBe('T7'); + expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2); + expect(notify).not.toHaveBeenCalled(); + }); + + it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => { + const linked = table('T1'); + expect(linked.clone().getStyle().연결).toBe('T1'); + const json = await linked.toJson(); + if (!json) throw new Error('표 JSON 없음'); + const back = await TableEntity.fromJson(json); + expect(back.getStyle().연결).toBe('T1'); + }); +}); + +describe('표 칸 갈기(update-table)', () => { + it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { + const linked = table('T1'); + const other = table('T2'); + setEntities([linked, other]); + const count = updateLinkedTables('T1', [ + [{ text: '항목(새)', colSpan: 2 }, null], + [{ text: '면적' }, { text: '99.0', align: 'right' }], + ]); + expect(count).toBe(1); + expect(linked.getCell(1, 1)?.text).toBe('99.0'); + expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로 + expect(other.getCell(1, 1)?.text).toBe('12.5'); + expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]); + expect(isDrawingDirty()).toBe(true); + }); + + it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => { + const linked = table('T1'); + setEntities([linked]); + updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]); + expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([ + ['a', 'b'], + ['', ''], + ]); + }); + + it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => { + setEntities([table('T1')]); + const before = getEntities(); + const notify = vi.fn(); + await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify); + expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 }); + expect(getEntities()).toBe(before); + }); + + 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 new file mode 100644 index 000000000..6ac5c13d1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -0,0 +1,80 @@ +/** + * 부모 화면 ↔ 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} + * 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함. + */ +import { toast } from 'react-toastify'; +import { runCommandInput } from '../commands/run-command.ts'; +import type { JsonEntity } from '../entities/Entity.ts'; +import { TableEntity } from '../entities/TableEntity.ts'; +import type { TableCells } from '../helpers/table-geometry.ts'; +import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts'; +import { setPendingTable } from '../tools/annotate/place-table-tool.ts'; + +export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table'; +export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table'; +export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated'; + +/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */ +interface TableMessage { + type?: string; + entity?: JsonEntity; + link?: string; + cells?: TableCells; +} + +/** 이 표가 그 연결의 것인가 */ +export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity => + entity instanceof TableEntity && entity.getStyle().연결 === link; + +/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */ +export function updateLinkedTables(link: string, cells: TableCells): number { + const tables = getEntities().filter((entity) => isLinkedTable(entity, link)); + for (const table of tables) table.replaceCells(cells); + if (tables.length) setEntities([...getEntities()], true); + return tables.length; +} + +/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */ +export async function startPlaceTable(entity: JsonEntity, link: string): Promise { + const table = await TableEntity.fromJson(entity as Parameters[0]); + const style = table.getStyle(); + setPendingTable( + new TableEntity( + table.layerId, + table.getOrigin(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells(), + { ...style, 연결: link } + ) + ); + runCommandInput('PLACETABLE'); +} + +const blocked = (): boolean => { + if (!isDrawingReadOnly() && !isViewOnly()) return false; + toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.'); + return true; +}; + +/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */ +export async function handleTableMessage( + data: unknown, + notify: (type: string, payload: Record) => void +): Promise { + const message = data as TableMessage | null; + if (!message || typeof message !== 'object') return false; + if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) { + if (!blocked()) await startPlaceTable(message.entity, message.link); + return true; + } + if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) { + const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells); + notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count }); + return true; + } + return false; +} diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index 1f051dab2..941badd93 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -113,6 +113,7 @@ export enum Tool { MLEADERSTYLE = 'MLEADERSTYLE', MLEADERALIGN = 'MLEADERALIGN', TABLE = 'TABLE', + PLACETABLE = 'PLACETABLE', TABLESTYLE = 'TABLESTYLE', TABLEEDIT = 'TABLEEDIT', TABLEROW = 'TABLEROW', diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts new file mode 100644 index 000000000..ce5331048 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts @@ -0,0 +1,49 @@ +/** + * 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게 + * 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음. + * 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다. + */ +import type { Point } from '@flatten-js/core'; +import { TableEntity } from '../../entities/TableEntity'; +import { addEntities, getActiveLayerId } from '../../state'; +import { Tool } from '../../tools'; +import { createSequenceTool } from '../factories/sequence-tool'; + +let pending: TableEntity | null = null; + +/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */ +export function setPendingTable(table: TableEntity | null): void { + pending = table; +} + +export function getPendingTable(): TableEntity | null { + return pending; +} + +/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */ +export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity { + const placed = new TableEntity( + layerId, + at.clone(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))), + table.getStyle() + ); + placed.lineColor = table.lineColor; + placed.lineWidth = table.lineWidth; + return placed; +} + +export const placeTableToolStateMachine = createSequenceTool({ + tool: Tool.PLACETABLE, + steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }], + helpers: true, + restart: false, + preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []), + commit: (input) => { + if (!pending) return; + addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true); + pending = null; + }, +}); From c7a8ed7cf9a9c72115c5f619b5aaeccb67283a06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:44:14 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(M02):=20=EC=9D=B4=EB=A6=84=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=ED=95=9C=20=EC=A4=84=20=ED=91=9C=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=E3=80=8C=ED=95=AD=EB=AA=A9=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=E2=80=A6=E3=80=8D(=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B8=80)=20=C2=B7=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B8=80=20=E3=80=8C=EB=8B=B4=EC=9D=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=E3=80=8D=20=E2=86=92=20=E3=80=8C=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=EB=A1=9C=EC=A7=81=E3=80=8D(40-1?= =?UTF-8?q?=20=EB=81=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 줄 표(compact) 머리만 · 상세 표 머리는 그대로 - 글 줄만: 넣을 값 빈 글 · 전체 항목 · 복사 생성 「단가산출 로직 수」 · 면적산출 알약 없음 · 실무문서 locale · B05 고르기 모달 일위대가 카드 · 면적 알약 없음 · 일위대가 문서 검사 글(「단가산출 로직 n째」) - 시험 기대값: test_m02_item_list 머리 글 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Doc.py | 2 +- M02_MasterTemplete/M02_Labor_Values.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_ItemList.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts | 5 +++-- M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts | 1 + M02_MasterTemplete/M02_MasterTemplete_Structure.ts | 2 +- resources/tester/test_m02_item_list.py | 2 +- ui_template/ui_template_item_picker_edit.ts | 2 +- ui_template/ui_template_item_picker_labor.ts | 2 +- ui_template/ui_template_locale_m2.ts | 2 +- 11 files changed, 13 insertions(+), 11 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 60be6edbe..eaf3979d3 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -67,7 +67,7 @@ def clean(doc: Any) -> dict[str, Any]: whole = m01.loaded()[1] lines, seen = [], set() for at, one in enumerate(doc.get("담은로직") or [], start=1): - where = f"담은 로직 {at}째" + where = f"단가산출 로직 {at}째" if not isinstance(one, dict): raise _bad(f"{where} · 묶음 아님") code = str(one.get("로직") or "") diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index ac0fbabe6..e70f8b978 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -127,7 +127,7 @@ const VALUE_TEXT = { title: "넣을 값", missing: "빠진 로직 {value}", edit: "넣을 값 수정", - none: "넣을 값이 없음 — 담은 로직에 입력이 없음", + none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 1df2b007c..a51a88c67 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -94,7 +94,7 @@ export const ITEM_COLS: Record[]> = { NAME, FOLDER, { head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3) - { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "단가산출 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, { head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note }, changed("labor"), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 34deef6f7..30fbaea13 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -62,7 +62,7 @@ const PRACTICE_COLS: Col[] = [ { head: "구분", get: (c) => c.구분 ?? "" }, { head: "상세구분", get: (c) => c.상세구분 ?? "" }, { head: "단위", get: (c) => c.단위 ?? "" }, - { head: "담은 로직 수", get: (c) => String(c.count) }, + { head: "단가산출 로직 수", get: (c) => String(c.count) }, ]; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 976a9976a..5189b76b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -381,10 +381,11 @@ export function compactTable(data: CompactData): HTMLElement { text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) }, }); + // 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계) const heads = [ - tc("Col_Name"), + tc("Labor_Item"), tc("Labor_QtyCol"), - tc("Col_Unit"), + tc("Labor_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ]; const lines = data.rows.map((row) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index d1df35571..cc1b30de5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -57,6 +57,7 @@ const TEXT = { Labor_AddMat: ["자재 추가", "Add material"], Labor_Qty: ["수량", "Quantity"], Labor_QtyCol: ["수량산출", "Quantity"], + Labor_Item: ["항목", "Item"], Labor_Hand: ["손", "Manual"], Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"], In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 44750848b..b51a094e2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -51,7 +51,7 @@ const TEXT = { pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", copyFailed: "복사 실패 — {value}", - noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", + noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 604f3d7ca..45d69c367 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -43,7 +43,7 @@ def test_종류마다_칸(ts): "이름", "폴더", "단위", - "담은 로직 수", + "단가산출 로직 수", "합계", "고친 날", ] diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index 892c5a84e..0958442c7 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -36,7 +36,7 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음", + noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음", drawing: "도면", area: "면적산출", inputs: "넣을 값", diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts index 69f14eaf1..2666dbebc 100644 --- a/ui_template/ui_template_item_picker_labor.ts +++ b/ui_template/ui_template_item_picker_labor.ts @@ -14,7 +14,7 @@ const TEXT = { title: "일위대가", none: "일위대가 없음", failed: "일위대가를 못 읽음 — {value}", - logics: "담은 로직 {value}개", + logics: "단가산출 로직 {value}개", materials: "자재 {value}개", base: "기준 합 {value}원", reflected: "반영 합 {value}원", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 78aa7c1c7..e6817a238 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -181,7 +181,7 @@ export const ui_locales_m2 = { M02_PracticeLogics: ["로직 수", "Logics"], M02_PracticeOrder: ["차례", "No."], M02_PracticeMemo: ["메모", "Memo"], - M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeNoLogic: ["단가산출 로직 없음", "No logics"], M02_PracticeBack: ["목록으로", "Back to list"], M02_PracticeDrop: ["통째 지우기", "Delete all"], M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], From 0bc5606638a2c2cf30f1195039c9785479089c79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:44:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(M02):=20CAD=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=B9=B8=20=EA=B0=88=EA=B8=B0=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=95=A8=EC=88=98=20=E2=80=94=20cad=5Fhos?= =?UTF-8?q?t=20placeTable=20=C2=B7=20updateTable=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EC=86=90=EC=9E=A1=EC=9D=B4=20(41-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cad_host: placeTable(entity, link) · updateTable(link, cells) → 바꾼 표 수 · 도면이 열리기 전이면 열린 뒤 보냄(싣기가 덮지 않게) · 5 초 넘으면 실패 - M02_MasterTemplete_Drawing: DrawingTemplateHandle 에 같은 함수 둘 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../M02_MasterTemplete_Drawing.ts | 5 ++ ui_template/cad_host/cad_host.ts | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 7650e1d2a..8aa61bc40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -77,6 +77,9 @@ function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): voi export interface DrawingTemplateHandle { getDoc: () => Promise; + /** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */ + placeTable: (entity: Record, link: string) => void; + updateTable: (link: string, cells: unknown[][]) => Promise; destroy: () => void; } @@ -277,6 +280,8 @@ export function mountDrawingTemplate( return { getDoc, + placeTable: cad.placeTable, + updateTable: cad.updateTable, destroy: () => { areaModal?.close(); cad.destroy(); diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 88c27c69e..db2f74a87 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -28,6 +28,10 @@ const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save"; * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; +/** 표(M02 41장) — 짝 = CAD `integration/aislo-table-bridge.ts` */ +const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table"; +const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table"; +const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated"; /** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */ export interface CadHostDrawing { @@ -93,6 +97,11 @@ export interface CadHost { ) => void; /** CAD 의 지금 편집본을 받는다. */ requestSave: () => Promise>; + /** 표 놓기(41장) — `entity` = 표 JSON(TableEntity 꼴) · CAD 가 `style.연결 = link` 로 커서에 붙여 놓게 함. + * 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */ + placeTable: (entity: Record, link: string) => void; + /** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */ + updateTable: (link: string, cells: unknown[][]) => Promise; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } @@ -177,6 +186,35 @@ export function createCadHost[] = []; + const postAfterLoad = (message: Record): void => { + if (drawingLoaded) post(message); + else afterLoad.push(message); + }; + const tableWaits = new Map void)[]>(); + + const placeTable: CadHost["placeTable"] = (entity, link) => + postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link }); + + const updateTable: CadHost["updateTable"] = (link, cells) => + new Promise((resolve, reject) => { + const waits = tableWaits.get(link) ?? []; + waits.push(resolve); + tableWaits.set(link, waits); + postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells }); + window.setTimeout(() => { + const left = tableWaits.get(link) ?? []; + if (!left.includes(resolve)) return; + tableWaits.set( + link, + left.filter((one) => one !== resolve), + ); + reject(new Error("CAD 표 갈기 응답 시간이 초과되었습니다.")); + }, 5000); + }); + const load: CadHost["load"] = ( drawing, meta, @@ -185,6 +223,7 @@ export function createCadHost { pendingLoad = { drawing, meta, frameEdit, frameFields, extra }; + drawingLoaded = false; // 새로 싣는 중 — 표는 열린 뒤에 if (!cadReady) return; post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra }); pendingLoad = undefined; @@ -215,6 +254,8 @@ export function createCadHost { stopLoadWatchdog(); window.removeEventListener("message", onMessage); From 3ded39875fb22e2f55898a51ef07e77c5248a9bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:45:09 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=B2=94=EC=9C=84=20=E2=86=92=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=ED=91=9C=20=EC=84=9C=EB=B2=84=20=E2=80=94=20Node=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20=EB=B3=80=ED=99=98=20=C2=B7=20=ED=91=9C=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EB=A9=B4=EC=A0=81=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=95=8C=20=EB=8F=84=EB=A9=B4=20=ED=91=9C=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B8=80=20=EB=A7=9E=EC=B6=A4=20(41-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_spreadsheet_node.ts areaTable: 시트 · 범위 → CAD 표 객체(표시 글 = formatValue · cellStyleAt · 병합 → colSpan/rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행열 뺌 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 칸 수 한도 · 칸 key 「시트id!A1」 · style.연결 빈 글) - 파이썬은 부르기만(common_util_spreadsheet.area_tables · 새 M02_Area_Table.py) - POST /api/m02/layers/{층}/area/{열}/table {시트, 범위, 글자크기} → {표, 시트, 범위, 값} · 저장 안 함 · 층 읽기 권한 - 면적 저장(Store.write · 비우기 · 복사 · 프로젝트 층 길) 뒤 그 열 도면 연결표마다 표 칸 글 다시 · 마지막 값 갱신 · 표 없으면 기록 둠 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M02_MasterTemplete/M02_Area_Table.py | 84 ++++++++ .../M02_MasterTemplete_Router_Items.py | 23 ++ .../M02_MasterTemplete_Router_Layers.py | 9 + .../M02_MasterTemplete_Store.py | 10 + common_util/common_util_spreadsheet.py | 11 + common_util/common_util_spreadsheet_node.ts | 150 ++++++++++++- resources/tester/test_m02_area_table.py | 201 ++++++++++++++++++ 7 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 M02_MasterTemplete/M02_Area_Table.py create mode 100644 resources/tester/test_m02_area_table.py diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py new file mode 100644 index 000000000..3dcea0a1c --- /dev/null +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -0,0 +1,84 @@ +"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환은 Node 번들 한 벌(`common_util_spreadsheet_node.ts` +`areaTable` · 화면 표시 글과 같은 코드) · 여기는 부르기 · 도면 표 맞춤만(파이썬 이식 없음). + +도면 문서(`structure/<열>.json`) 맨 위 `연결표` = `[{연결, 시트, 범위, 글자크기, 값}]` — 도면 표 +`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 · +칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용). +도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from common_util import common_util_spreadsheet as sheet_node +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +logger = logging.getLogger(__name__) +LINKS = "연결표" + + +def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> dict[str, Any]: + """그 열 면적산출(저장본) 시트 · 범위 → `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400.""" + book = store.read("area", column)["문서"] + try: + got = sheet_node.area_tables([{"book": book, "시트": sheet, "범위": rng, "글자크기": font}]) + 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("까닭")) + return {k: got[0][k] for k in ("표", "시트", "범위", "값")} + + +def _style_link(entity: Any) -> Any: + if not isinstance(entity, dict) or entity.get("type") != "Table": + return None + return ((entity.get("shapeData") or {}).get("style") or {}).get("연결") + + +def refresh_drawing(column: str) -> list[str]: + """지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.""" + try: + drawing, area = store._path("structure", column), store._path("area", column) + except store.StoreError: + return [] + 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("연결")] + if not links: + return [] + book = json.loads(area.read_bytes()) + asks = [ + {"book": book, "시트": x.get("시트"), "범위": x.get("범위"), "글자크기": x.get("글자크기")} + for x in links + ] + try: + results = sheet_node.area_tables(asks) + except sheet_node.RecalcError as e: + logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e) + return [] + entities = (doc.get("도면") or {}).get("entities") or [] + done: list[str] = [] + for link, got in zip(links, results): + if not got.get("ok"): + continue + link["값"] = got["값"] + new = got["표"]["shapeData"] + for entity in entities: + if _style_link(entity) != link["연결"]: + continue + shape = entity["shapeData"] + if (len(shape.get("rowHeights") or []), len(shape.get("columnWidths") or [])) != ( + len(new["rowHeights"]), + len(new["columnWidths"]), + ): + shape["rowHeights"], shape["columnWidths"] = new["rowHeights"], new["columnWidths"] + shape["cells"] = new["cells"] + done.append(link["연결"]) + with store._LOCK: + atomic_write_json(drawing, doc) + return done diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 413e5409c..3b709f945 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,29 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +class AreaTableBody(BaseModel): + """면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `글자크기` 기본 2.5.""" + + 시트: str + 범위: str + 글자크기: float | None = None + + +@router.post("/layers/{layer}/area/{column}/table") +def post_area_table( + layer: Layer, + column: str, + body: AreaTableBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """그 열 면적산출(저장본) 범위 → CAD 표 `{표, 시트, 범위, 값}` — 저장 안 함 · 층 읽기 권한.""" + from M02_MasterTemplete import M02_Area_Table as area_table + + folder = _root(session, layer, owner) + return _call(folder, area_table.to_table, column, body.시트, body.범위, body.글자크기) + + @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 639f400c2..ac26926b7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -361,9 +361,18 @@ async def save_layer_template( ) from error except ValueError as error: raise _bad(error) from error + if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가) + await asyncio.to_thread(_refresh_drawing, folder, name) return {"종류": kind, "이름": name, "판": version, "층": layer} +def _refresh_drawing(folder: Path, column: str) -> None: + from M02_MasterTemplete import M02_Area_Table as area_table + + with store.at(folder): + area_table.refresh_drawing(column) + + def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict: with store.at(folder): try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0873d5d5e..6fcb2f702 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -170,6 +170,7 @@ 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) if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: @@ -362,6 +363,7 @@ def clear(kind: str, column: str) -> dict[str, Any]: info = _info(kind, _path(kind, column)) if kind == "area": _refresh_labor(column) + _refresh_drawing(column) sync_combos() return info @@ -391,6 +393,7 @@ def copy_from( info = _info(kind, _path(kind, column)) if kind == "area": _refresh_labor(column) + _refresh_drawing(column) return info @@ -482,6 +485,13 @@ def _server(kind: str, doc: Any) -> Any: return doc +def _refresh_drawing(column: str) -> None: + """그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1).""" + from M02_MasterTemplete import M02_Area_Table as area_table + + area_table.refresh_drawing(column) + + def _refresh_labor(column: str) -> None: """그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠.""" path = _path("labor", column) diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py index 17044da69..68afa4218 100644 --- a/common_util/common_util_spreadsheet.py +++ b/common_util/common_util_spreadsheet.py @@ -54,3 +54,14 @@ def with_server_values(book: dict[str, Any]) -> dict[str, Any]: clean.pop("계산값", None) clean["계산값"] = recalc_workbooks([clean])[0] return clean + + +def area_tables(asks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """면적산출 시트 · 범위 → CAD 표 객체(41-1 · 변환은 번들 `areaTable` 한 벌) — 물음 차례 그대로 + `{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`.""" + if _stale() and not build_bundle(NPM_SCRIPT): + raise RecalcError("스프레드시트 계산 번들을 못 만듦") + out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"tables": asks}) + if out is None: + raise RecalcError("스프레드시트 계산 번들 실행 실패") + return out["tables"] diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index 420f7448a..fbf7a7736 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -6,15 +6,145 @@ * 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`. * * 실행: node <번들> <입력.json> <출력.json> - * 입력 { books: Workbook[] } - * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂 + * 입력 { books?: Workbook[], tables?: TableAsk[] } + * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[], + * tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂 + * 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · + * 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함. * 끝 코드: 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 { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; +import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */ +interface TableAsk { + book: Workbook; + 시트: string; + 범위: string; + 글자크기?: number; + 한도?: number; +} + +/** 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; + const sheet = + book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트); + if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`); + const range = parseRange(ask.범위); + if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + 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), 값 }; +} const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { @@ -22,7 +152,10 @@ if (!inputPath || !outputPath) { process.exit(2); } -const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] }; +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + books?: Workbook[]; + tables?: TableAsk[]; +}; const results = (input.books ?? []).map((book) => { try { return { ok: true, 계산값: recalcWorkbook(book) }; @@ -30,4 +163,11 @@ const results = (input.books ?? []).map((book) => { return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; } }); -writeFileSync(outputPath, JSON.stringify({ results })); +const tables = (input.tables ?? []).map((ask) => { + try { + return { ok: true, ...areaTable(ask) }; + } catch (error) { + return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; + } +}); +writeFileSync(outputPath, JSON.stringify({ results, tables })); diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py new file mode 100644 index 000000000..0f93efab7 --- /dev/null +++ b/resources/tester/test_m02_area_table.py @@ -0,0 +1,201 @@ +"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환(Node 번들 `areaTable`) · 길 · 저장 때 도면 표 맞춤. + +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 마스터 양식 · storage 는 안 씀. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_spreadsheet as sheet_node +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Area_Table as area_table +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +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}}, +} +BOOK = { + "종류": "통합문서", + "판": 1, + "열": "rv", + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "시트": [SHEET], + "활성": "s1", +} + + +def _one(**ask) -> dict: + return sheet_node.area_tables([{"book": BOOK, **ask}])[0] + + +def test_표시_글_형식_굵게_정렬_칸_key(): + got = _one(시트="면적산출", 범위="A1:B2") + cells = got["표"]["shapeData"]["cells"] + assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"} + assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식 + assert cells[0][0]["align"] == "center" # 가로 서식 + assert (got["시트"], got["범위"]) == ("s1", "A1:B2") + assert got["값"]["s1!B2"] == "2,469.00" + assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""} + + +def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): + shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"] + cells = shape["cells"] + assert len(cells) == 3 and all(len(row) == 3 for row in cells) # 행 3 · 열 D 뺌 + assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None + assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김) + assert cells[1][2] is None and cells[2][2] is None + assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸 + assert shape["style"]["fontSize"] == 5 + assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율 + assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3 + + +@pytest.mark.parametrize( + "ask, why", + [ + ({"시트": "없음", "범위": "A1"}, "없는 시트"), + ({"시트": "s1", "범위": "가나"}, "범위 꼴 아님"), + ({"시트": "s1", "범위": "A3"}, "보이는 칸이 없음"), + ({"시트": "s1", "범위": "A1:Z200", "한도": 100}, "칸이 너무 많음"), + ({"시트": "s1", "범위": "A1:J20", "한도": 100}, "칸이 너무 많음 171"), + ], +) +def test_못_바꾸는_범위는_까닭(ask, why): + got = _one(**ask) + assert not got["ok"] and why in got["까닭"] + + +# ── 길 · 저장 때 맞춤 ────────────────────────────────────────────────── +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + got = store.read("area", "rv") + store.write("area", "rv", got["판"], {**got["문서"], "서식": BOOK["서식"], "시트": [SHEET]}) + items_store.ensure(layers.personal_dir(7, 99)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system} + + +URL = "/api/m02/layers/{}/area/{}/table" + + +def test_길_읽기_권한이면_표_저장_안_함_남의_층_403_없는_열_404(world): + client, system = world["client"], world["system"] + before = (system / "area" / "rv.json").read_bytes() + got = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "A1:B2"}) + assert got.status_code == 200, got.text + assert got.json()["표"]["shapeData"]["cells"][0][1]["text"] == "1,234.50" + assert (system / "area" / "rv.json").read_bytes() == before # 저장 안 함 + bad = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "가"}) + assert bad.status_code == 400 + other = client.post( + URL.format("personal", "rv") + "?owner=7/99", json={"시트": "s1", "범위": "A1"} + ) + assert other.status_code == 403 + assert ( + client.post(URL.format("system", "없는열"), json={"시트": "s1", "범위": "A1"}).status_code + == 404 + ) + + +def _link_drawing(folder: Path) -> None: + """도면에 연결표 둘(T1 = 표 있음 · T2 = 표 없음) + 낡은 칸 글의 T1 표 하나 · 다른 표 하나.""" + with store.at(folder): + got = store.read("structure", "rv") + doc = got["문서"] + stale = { + "type": "Table", + "shapeData": { + "origin": {"x": 10, "y": 20}, + "columnWidths": [9, 9], + "rowHeights": [3, 3], + "cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]], + "style": {"fontSize": 2.5, "연결": "T1"}, + }, + } + other = json.loads(json.dumps(stale)) + other["shapeData"]["style"]["연결"] = "남의표" + doc["도면"]["entities"] = [stale, other] + doc["연결표"] = [ + {"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}}, + {"연결": "T2", "시트": "s1", "범위": "B1", "값": {}}, + ] + store.write("structure", "rv", got["판"], doc) + + +def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없는_기록은_둠(world): + system = world["system"] + _link_drawing(system) + with store.at(system): + got = store.read("area", "rv") + got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1} + store.write("area", "rv", got["판"], got["문서"]) + doc = store.read("structure", "rv")["문서"] + t1, other = doc["도면"]["entities"] + shape = t1["shapeData"] + assert shape["cells"][0][1]["text"] == "10.00" and shape["cells"][1][1]["text"] == "20.00" + assert (shape["origin"], shape["columnWidths"], shape["rowHeights"]) == ( + {"x": 10, "y": 20}, + [9, 9], + [3, 3], + ) # 칸 수 같으면 크기 그대로 + assert other["shapeData"]["cells"][0][0]["text"] == "낡음" # 다른 연결 표는 안 건드림 + t1_rec, t2_rec = doc["연결표"] + assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록 + + +def test_프로젝트_층_길도_같은_맞춤(world, tmp_path): + project = tmp_path / "project" + import shutil + + shutil.copytree(world["system"], project) # 프로젝트 작업본 꼴(같은 종류 폴더) + _link_drawing(project) + area = json.loads((project / "area" / "rv.json").read_bytes()) + area["시트"][0]["칸"]["B1"] = {"값": 7, "서식": 1} + (project / "area" / "rv.json").write_text( + json.dumps(area, ensure_ascii=False), encoding="utf-8" + ) + layers_router._refresh_drawing(project, "rv") + doc = json.loads((project / "structure" / "rv.json").read_bytes()) + assert doc["도면"]["entities"][0]["shapeData"]["cells"][0][1]["text"] == "7.00" + system_doc = json.loads((world["system"] / "structure" / "rv.json").read_bytes()) + assert "연결표" not in system_doc # 시스템 층은 그대로 + assert area_table.refresh_drawing("없는열") == []