feat(M02): 면적산출 범위 → 도면 표 서버 — Node 번들 변환 · 표 길 · 면적 저장 때 도면 표 칸 글 맞춤 (41-1)

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-04 22:45:09 +09:00
co-authored by Claude Opus 5.5
parent 7ca49764ce
commit 3ded39875f
7 changed files with 483 additions and 5 deletions
+84
View File
@@ -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
@@ -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,
@@ -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:
@@ -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)
+11
View File
@@ -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"]
+145 -5
View File
@@ -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<Record<HAlign, TableCell["align"]>> = {
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<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), 값 };
}
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 }));
+201
View File
@@ -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("없는열") == []