feat(M02): 면적 값 고침이 저장 전 도면 패널 표에 바로 비침 · 이름 · 보내기 취소는 [저장] 때 서버가 기록(112-1·112-2)

- 112-1 면적 표 변환을 공용 파일(spreadsheet_area_table.ts)로 빼 서버 Node 와 브라우저가 한 벌로 씀 · 도면 패널이 칸 고침마다(값 바뀐 표만 · 펼친 동안만) CAD 연결 표 갈기를 보기 전용 갈음(preview)으로 보냄
- 112-2 보낸 범위 이름은 도면 [저장] 요청 안에서 서버가 면적 문서에 둠(놓기만 하고 저장 안 하면 안 생김) · [보내기 취소]는 화면에서만 빼고 면적 [저장] 때 서버가 연결표 줄 · 이름을 뺌 · /name · /unlink 길 삭제

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-08 17:36:44 +09:00
co-authored by Claude Sonnet 5.5
parent 2a830980c7
commit 5638bd5101
17 changed files with 797 additions and 322 deletions
@@ -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<Record<HAlign, TableCell["align"]>> = {
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<string, string> = {};
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), 값 };
}
@@ -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);
});
@@ -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;
}
+72 -30
View File
@@ -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)
@@ -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<string>;
}
/** 도면 손잡이 중 실시간 갈기에 쓰는 것(`mountStructureSheets` 의 `updateTable`) */
interface LiveHandle {
updateTable?: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
}
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<string, Record<string, string>>();
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<void> {
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<typeof mountStructureSheets>[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();
@@ -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<string>;
}
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 {
@@ -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<void> {
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<string, string> = {}, b: Record<string, string> = {}): boolean => {
export const sameValues = (
a: Record<string, string> = {},
b: Record<string, string> = {},
): 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<boolean> {
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<NonNullable<LinkOptions["표시범위"]>> {
export async function sentRanges(
at: Selection,
onCancel: (연결: string) => void,
): Promise<NonNullable<LinkOptions["표시범위"]>> {
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<NonNullable<LinkOptions
범위이름: row.이름,
이름: fill(TEXT.tag, "id", row.연결),
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
취소: () => 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<string>;
/** 면적 저장 문서 — 취소한 연결이 있으면 `보내기취소[]` 를 실음(서버가 줄 · 이름을 뺌) */
withCancels: <T extends object>(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<string>();
/** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */
const askSize = (spec: { 범위: string }): Promise<number | null> =>
@@ -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) => {
@@ -94,7 +94,7 @@ export interface DrawingTemplateHandle {
getDoc: () => Promise<DrawingTemplateDoc>;
/** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */
placeTable: (entity: Record<string, unknown>, link: string) => void;
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
/** 다른 장을 같은 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,
};
@@ -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,
@@ -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:
+22 -5
View File
@@ -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:
@@ -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<StructureDoc, "도면" | "더한장">),
})
: at.kind === "area" && loaded
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
? toDrawing.withCancels({
...(loaded as Record<string, unknown>),
...(edited as Record<string, unknown>),
}) // + 저장 전 [보내기 취소] 연결(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) {
+6 -146
View File
@@ -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<Record<HAlign, TableCell["align"]>> = {
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<string, string> = {};
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;
+224
View File
@@ -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"]
+111 -25
View File
@@ -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):
+45 -5
View File
@@ -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]
+8 -3
View File
@@ -104,7 +104,7 @@ export interface CadHost<D extends CadHostDrawing, M, Q> {
* 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */
placeTable: (entity: Record<string, unknown>, link: string) => void;
/** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */
updateTable: (link: string, cells: unknown[][]) => Promise<number>;
updateTable: (link: string, cells: unknown[][], preview?: boolean) => Promise<number>;
/** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */
destroy: () => void;
}
@@ -201,12 +201,17 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
const placeTable: CadHost<D, M, Q>["placeTable"] = (entity, link) =>
postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link });
const updateTable: CadHost<D, M, Q>["updateTable"] = (link, cells) =>
const updateTable: CadHost<D, M, Q>["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;