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:
@@ -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("없는열") == []
|
||||
Reference in New Issue
Block a user