feat(m02): 구조물 번호 SD·SC 6자리 · 집계표 저장 때 파일 만들기/지우기 · 복사 가져오기 길

- 도번 SD · 표번 SC + [종류][분류1][분류2] — 제목행(열 머리 세 층) 차례 · 없는 층 00 · 한 열 두 번호 같은 6자리 · 준 번호 유지(새 열 = 그 무리 다음 번호 · 지운 번호 비움)
- 구조물 = 꼴=수 열만(NO · 측점 · 비고 빠짐 · 브레인 판정)
- 집계표 [저장] — 새 열 두 파일 만들고 지운 열 두 파일 지움
- 새 산출근거 = 진짜 빈 시트(머리 줄 없음) · renumber() = 옛 번호 한 번 옮김 + 옛 기본 머리 줄 지움
- POST /structures/{열}/copy?from= · /basis/{열}/copy?from= — 내용만 · 번호 · 열은 받는 쪽
- GET /structures/numbers = {열 id: {도번, 표번}} · /basis/numbers 뺌
- 시험: test_m02_structure · test_spreadsheet_store 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
2026-09-28 09:55:35 +09:00
co-authored by Claude Opus 5.5
parent e4eef2a47b
commit 3a4b306636
4 changed files with 320 additions and 89 deletions
@@ -7,7 +7,7 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from M02_MasterTemplete import M02_MasterTemplete_Store as store
@@ -39,13 +39,20 @@ def get_structure_numbers() -> dict:
@router.post("/structures/{column}")
def post_structure(column: str) -> dict:
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · A1 도각 + 빈 산출근거 표 · 이미 있으면 409."""
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 산출근거도 · 이미 있으면 409."""
return _call(store.create_structure, column)
@router.get("/basis/numbers")
def get_basis_numbers() -> dict:
return _call(store.basis_numbers)
@router.post("/structures/{column}/copy")
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
"""다른 열 구조물 도면 내용을 가져옴 — 도번 · 열은 받는 쪽 그대로."""
return _call(store.copy_structure, column, source)
@router.post("/basis/{column}/copy")
def post_basis_copy(column: str, source: str = Query(alias="from")) -> dict:
"""다른 열 산출근거 내용을 가져옴 — 표번 · 열은 받는 쪽 그대로."""
return _call(store.copy_basis, column, source)
@router.get("/templates/{kind}/{name}")
+184 -57
View File
@@ -20,8 +20,10 @@ from M02_MasterTemplete import M02_Template_Layers as layers
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면(그린 만큼이 설계 영역)
# 구조물 도면 = 구조물집계표 구조물 열(꼴=수) 마다 하나(파일 이름 = 열 id) · 도각 없는 전용 화면
# 산출근거 = 같은 열 id 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서)
# 번호 = 도번 SD · 표번 SC + 6자리 [종류][분류1][분류2](제목행 = 열 `머리` 세 층 · 없는 층 00) —
# 한 열의 두 번호는 같은 6자리 · 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 12장)
STRUCTURE_TABLE = "구조물집계표"
_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)")
_BASIS_HEAD_STYLE = {
@@ -30,8 +32,7 @@ _BASIS_HEAD_STYLE = {
"세로": "center",
"테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")},
}
_NUMBER = re.compile(r"^구-(\d+)$")
_TABLE_NUMBER = re.compile(r"^표-(\d+)$")
_CODE = re.compile(r"^S[DC](\d{6})$")
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
# of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침)
@@ -93,8 +94,8 @@ def _check_skeleton(kind: str, doc: Any) -> None:
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
"""`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀.
구조물집계표를 저장하면 — 새 열의 빈 구조물 도면 · 산출근거도 같이 만듦(잠금 밖에서 ·
`create_structure` 가 다시 잠금을 잡으므로 안에서 부르면 죽음).
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 산출근거를 번호와 같이 만들고
지운 열(또는 구조물이 아니게 된 열)의 두 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
(브라우저 값 버림 · 5장 ②)."""
path = _path(kind, name)
@@ -108,7 +109,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
atomic_write_json(path, doc)
info = _info(kind, path)
if kind == "table" and name == STRUCTURE_TABLE:
backfill_missing_structures()
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
prune_removed_structures()
return info
@@ -130,46 +132,131 @@ def _structures() -> list[tuple[str, Any]]:
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
def structure_numbers() -> dict[str, str | None]:
"""`{열 id: 도번}` — 집계표 도면 줄이 한 번에 받음."""
return {
name: (doc.get("도번") if isinstance(doc, dict) else None) for name, doc in _structures()
}
def _code(doc: Any, key: str) -> str | None:
"""문서의 새 번호 6자리 — 옛 `구-01` · `표-01` · 없음은 None."""
found = _CODE.match(str(doc.get(key) or "")) if isinstance(doc, dict) else None
return found.group(1) if found else None
def _number(doc: Any) -> int:
found = _NUMBER.match(str(doc.get("도번") or "")) if isinstance(doc, dict) else None
return int(found.group(1)) if found else 0
def _codes() -> dict[str, str]:
"""`{열 id: 6자리}` — 구조물 도면 도번 먼저 · 없으면 산출근거 표번."""
codes = {name: c for name, doc in _basis_docs() if (c := _code(doc, "표번"))}
codes.update({name: c for name, doc in _structures() if (c := _code(doc, "도번"))})
return codes
def structure_numbers() -> dict[str, dict[str, str]]:
"""`{열 id: {도번, 표번}}` — 집계표 도면 · 산출근거 줄 · 좌측 줄이 한 번에 받음
(없는 쪽은 빈 글)."""
out: dict[str, dict[str, str]] = {}
for name, doc in _structures():
out.setdefault(name, {"도번": "", "표번": ""})["도번"] = str(doc.get("도번") or "")
for name, doc in _basis_docs():
out.setdefault(name, {"도번": "", "표번": ""})["표번"] = str(doc.get("표번") or "")
return out
def _heads(col: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
"""제목행 세 층(종류 · 분류1 · 분류2) — 빈 층은 None."""
parts = [*(col.get("머리") or []), None, None, None][:3]
clean = [str(p).strip() if p is not None else "" for p in parts]
return (clean[0] or None, clean[1] or None, clean[2] or None)
def _structure_columns() -> list[dict[str, Any]]:
"""번호 · 파일을 받는 열 — 꼴=수(NO · 측점 · 비고 같은 글 열은 구조물 아님)."""
table = read("table", STRUCTURE_TABLE)["문서"]
return [
col
for col in table.get("열", [])
if isinstance(col, dict) and col.get("id") and col.get("꼴") == "수"
]
def _column_ids() -> list[str]:
table = read("table", STRUCTURE_TABLE)["문서"]
return [col["id"] for col in table.get("열", []) if isinstance(col, dict) and col.get("id")]
return [col["id"] for col in _structure_columns()]
def _pick_code(col: dict[str, Any], taken: dict[str, str], heads: dict[str, tuple]) -> str:
"""새 열 번호 — 같은 종류(· 분류1)가 이미 번호를 가졌으면 그 두 자리를 이어 받고 · 없으면 그 층
가장 큰 번호 + 1 · 빈 층은 00. 같은 6자리가 이미 있으면(같은 제목행 · 빈 분류2 겹침) 분류2 를
그 무리 가장 큰 번호 + 1 로. `taken` = 이미 번호 있는 열 `{열 id: 6자리}` ·
`heads` = 열 id → 제목행."""
kind, first, second = _heads(col)
codes = list(taken.values())
mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads]
def next_of(prefix: str, at: slice) -> str:
found = [int(c[at]) for c in codes if c.startswith(prefix)]
return f"{max(found, default=0) + 1:02d}"
k = next((c[:2] for h, c in mates if h[0] == kind), None) or next_of("", slice(0, 2))
if first is None:
a = "00"
else:
same = (c[2:4] for h, c in mates if h[:2] == (kind, first) and c[:2] == k)
a = next(same, None) or next_of(k, slice(2, 4))
b = "00" if second is None else next_of(k + a, slice(4, 6))
if k + a + b in codes:
b = next_of(k + a, slice(4, 6))
return k + a + b
def _new_code(column: str) -> str:
"""이 열이 쓸 6자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
taken = _codes()
if column in taken:
return taken[column]
cols = _structure_columns()
col = next((c for c in cols if c["id"] == column), {"id": column})
return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols})
def create_structure(column: str) -> dict[str, Any]:
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409.
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 6자리(산출근거와 같은 6자리) · 이미 있으면 409.
산출근거 파일이 없으면 빈 통합문서도 같이 만듦."""
path = _path("structure", column)
if column not in _column_ids():
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
with _LOCK:
if path.is_file():
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
number = max((_number(doc) for _name, doc in _structures()), default=0) + 1
doc = {
"양식": "구조물도면",
"종류": "structure",
"판": 1,
"열": column,
"도번": f"구-{number:02d}",
"도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]},
}
atomic_write_json(path, doc)
atomic_write_json(path, {**_blank_structure(column), "도번": f"SD{_new_code(column)}"})
ensure_basis(column)
return read("structure", column)
def _blank_structure(column: str) -> dict[str, Any]:
"""빈 구조물 도면(도번은 부르는 쪽이 붙임) — 도각 없음 · 기본 도면층 하나."""
return {
"양식": "구조물도면",
"종류": "structure",
"판": 1,
"열": column,
"도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]},
}
def copy_structure(column: str, source: str) -> dict[str, Any]:
"""다른 열 구조물 도면 내용을 가져옴 — 도번 · 열은 받는 쪽 그대로(두 열은 따로 움직임)."""
return _copy("structure", column, source, ("열", "도번"))
def copy_basis(column: str, source: str) -> dict[str, Any]:
"""다른 열 산출근거 내용을 가져옴 — 표번 · 열은 받는 쪽 그대로 · 계산값은 서버가 다시 풂."""
return _copy("basis", column, source, ("열", "표번"))
def _copy(kind: str, column: str, source: str, keep: tuple[str, ...]) -> dict[str, Any]:
target = read(kind, column)["문서"]
doc = {**read(kind, source)["문서"], **{key: target.get(key) for key in keep}}
if kind == "basis":
doc = _server_values({k: v for k, v in doc.items() if k != "계산값"})
with _LOCK:
atomic_write_json(_path(kind, column), doc)
return _info(kind, _path(kind, column))
# ── 산출근거 ──────────────────────────────────────────
@@ -183,7 +270,7 @@ def _col_letters(index: int) -> str:
def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
"""빈 산출근거 통합문서 — 1 행 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당)).
"""산출근거 통합문서 — 새로는 진짜 빈 시트(머리 줄 없음).
`old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김
(옛 열 식은 없어 안 옮김)."""
columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None
@@ -192,7 +279,7 @@ def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
" ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns
]
else:
heads, columns = list(_BASIS_HEAD), []
heads, columns = [], []
cells: dict[str, Any] = {
f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head
}
@@ -216,7 +303,7 @@ def basis_doc(column: str, old: Any = None) -> dict[str, Any]:
"종류": "통합문서",
"판": 1,
"열": column,
"서식": [{}, _BASIS_HEAD_STYLE],
"서식": [{}, _BASIS_HEAD_STYLE] if heads else [{}],
"시트": [sheet],
"활성": "s1",
}
@@ -227,40 +314,80 @@ def _basis_docs() -> list[tuple[str, Any]]:
return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))]
def _table_number(doc: Any) -> int:
found = _TABLE_NUMBER.match(str(doc.get("표번") or "")) if isinstance(doc, dict) else None
return int(found.group(1)) if found else 0
def ensure_basis(column: str, old: Any = None) -> bool:
"""산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음).
표번 = 도번과 같은 규칙(있는 것 중 가장 큰 번호 + 1) · 만든 뒤로는 안 바뀜."""
표번 = SC + 구조물 도면과 같은 6자리 · 만든 뒤로는 안 바뀜."""
path = _path("basis", column)
with _LOCK:
if path.is_file():
return False
number = max((_table_number(doc) for _name, doc in _basis_docs()), default=0) + 1
doc = {**basis_doc(column, old), "표번": f"표-{number:02d}"}
doc = {**basis_doc(column, old), "표번": f"SC{_new_code(column)}"}
atomic_write_json(path, doc)
return True
def basis_numbers() -> dict[str, str | None]:
"""`{열 id: 표번}` — 좌측 산출근거 줄이 한 번에 받음.
표번이 없는 옛 파일은 여기서 만나는 순서로 채움(도번 규칙과 같음)."""
docs = _basis_docs()
missing = [name for name, doc in docs if not (isinstance(doc, dict) and doc.get("표번"))]
if missing:
with _LOCK:
docs = _basis_docs() # 잠금 안에서 다시 읽음 — 그 사이 누가 채웠을 수 있음
number = max((_table_number(doc) for _name, doc in docs), default=0)
for name, doc in docs:
if isinstance(doc, dict) and not doc.get("표번"):
number += 1
doc["표번"] = f"표-{number:02d}"
atomic_write_json(_path("basis", name), doc)
docs = _basis_docs()
return {name: (doc.get("표번") if isinstance(doc, dict) else None) for name, doc in docs}
def _strip_default_head(doc: Any) -> bool:
"""옛 빈 산출근거의 1 행 기본 머리(공종 · 규격 · 산출 내역 · 단위 · 수량(m당))를 지움 —
다섯 칸이 모두 그 글일 때만(사용자가 고친 머리는 둠) · 머리 서식만 쓰였으면 서식 표도 비움."""
sheets = doc.get("시트") if isinstance(doc, dict) else None
cells = sheets[0].get("칸") if isinstance(sheets, list) and sheets else None
keys = [f"{_col_letters(i)}1" for i in range(len(_BASIS_HEAD))]
if not isinstance(cells, dict) or [(cells.get(k) or {}).get("값") for k in keys] != list(
_BASIS_HEAD
):
return False
for k in keys:
del cells[k]
if not any(isinstance(c, dict) and c.get("서식") for s in sheets for c in s["칸"].values()):
doc["서식"] = [{}]
return True
def prune_removed_structures() -> list[str]:
"""집계표에서 지운 열(또는 구조물이 아니게 된 열)의 구조물 도면 · 산출근거를 같이 지움 —
번호는 비워 둠(다른 열 번호를 안 당김)."""
keep = set(_column_ids())
gone: list[str] = []
with _LOCK:
for kind in ("structure", "basis"):
for path in sorted((FOLDER / kind).glob("[!.]*.json")):
if path.stem not in keep:
path.unlink()
gone.append(f"{kind}/{path.stem}")
return gone
def renumber() -> dict[str, list[str]]:
"""한 번 — 옛 번호(구-NN · 표-NN · 없음)를 새 번호로(열 차례 = 종류 · 분류1 · 분류2 차례) ·
옛 빈 산출근거 기본 머리 줄 지움 · 구조물 아닌 열 파일 지움 · 빠진 파일 만듦.
내용은 그대로 · 새 번호가 이미 있으면 그대로(두 번 돌려도 같음)."""
gone = prune_removed_structures()
changed: list[str] = []
made: list[str] = []
with _LOCK:
cols = _structure_columns()
heads = {c["id"]: _heads(c) for c in cols}
taken = _codes()
for col in cols:
if col["id"] not in taken:
taken[col["id"]] = _pick_code(col, taken, heads)
docs = {"structure": dict(_structures()), "basis": dict(_basis_docs())}
for col in cols:
cid = col["id"]
for kind, key, head in (("structure", "도번", "SD"), ("basis", "표번", "SC")):
number = f"{head}{taken[cid]}"
doc = docs[kind].get(cid)
if doc is None:
blank = _blank_structure(cid) if kind == "structure" else basis_doc(cid)
atomic_write_json(_path(kind, cid), {**blank, key: number})
made.append(f"{kind}/{cid}")
continue
stripped = kind == "basis" and _strip_default_head(doc)
if doc.get(key) != number or stripped:
doc[key] = number
atomic_write_json(_path(kind, cid), doc)
changed.append(f"{kind}/{cid}")
return {"지움": gone, "바꿈": changed, "새로": made}
def _server_values(doc: Any) -> dict[str, Any]:
@@ -288,8 +415,8 @@ def migrate_basis() -> dict[str, list[str]]:
def backfill_missing_structures() -> list[str]:
"""구조물집계표 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로
만듦(도번 이어서).
"""구조물 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로
만듦(그 무리 다음 번호).
그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀."""
have = {name for name, _ in _structures()}
made: list[str] = []
@@ -80,35 +80,58 @@ def test_옛_산출근거를_따로_파일로_옮김(client, tmp_path):
assert before == {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()}
def test_새_구조물_도면은_산출근거_따로(client):
def test_새_구조물_도면은_산출근거_따로_진짜_빈_시트(client):
made = client.post("/api/m02/structures/rv").json()["문서"]
assert "산출근거" not in made
basis = client.get("/api/m02/templates/basis/rv").json()["문서"]
heads = [basis["시트"][0]["칸"][f"{c}1"]["값"] for c in "ABCDE"]
assert heads == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
assert basis["시트"][0]["칸"] == {} and basis["서식"] == [{}] # 머리 줄 없음
def test_표번은_도번과_같은_규칙(client):
def test_표번은_도번과_같은_6자리(client):
made1 = client.post("/api/m02/structures/rv").json()["문서"]
made2 = client.post("/api/m02/structures/pv_b").json()["문서"]
assert made1["도번"] == "구-01" and made2["도번"] == "구-02"
assert made1["도번"] == "SD010101" and made2["도번"] == "SD020101"
basis1 = client.get("/api/m02/templates/basis/rv").json()["문서"]
basis2 = client.get("/api/m02/templates/basis/pv_b").json()["문서"]
assert basis1["표번"] == "표-01" and basis2["표번"] == "표-02"
numbers = client.get("/api/m02/basis/numbers").json()
assert numbers["rv"] == "표-01" and numbers["pv_b"] == "표-02"
assert basis1["표번"] == "SC010101" and basis2["표번"] == "SC020101"
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["pv_b"] == {"도번": "SD020101", "표번": "SC020101"}
def test_표번_없는_옛_파일은_만나면_채움(client, tmp_path):
store.ensure_basis("rv")
doc = store.read("basis", "rv")["문서"]
del doc["표번"]
(tmp_path / "basis" / "rv.json").write_text(
def _old_basis(folder, column, cells):
doc = {**store.basis_doc(column), "표번": "표-03", "서식": [{}, {"굵게": True}]}
doc["시트"][0]["칸"] = cells
(folder / "basis" / f"{column}.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
numbers = client.get("/api/m02/basis/numbers").json()
assert numbers["rv"] == "표-01"
assert store.read("basis", "rv")["문서"]["표번"] == "표-01" # 한 번 채우면 파일에 남음
def test_옛_빈_산출근거_머리_줄을_지우고_새_번호(client, tmp_path):
head = {f"{c}1": {"값": v, "서식": 1} for c, v in zip("ABCDE", store._BASIS_HEAD)}
_old_basis(tmp_path, "rv", dict(head))
_old_basis(tmp_path, "ms", {**head, "A1": {"값": "공종명", "서식": 1}}) # 사용자가 고친 머리
_old_basis(tmp_path, "sg", {**head, "A2": {"값": "터파기"}}) # 아래 줄에 내용
store.renumber()
rv = store.read("basis", "rv")["문서"]
assert (rv["표번"], rv["시트"][0]["칸"], rv["서식"]) == ("SC010101", {}, [{}])
assert store.read("basis", "ms")["문서"]["시트"][0]["칸"]["A1"]["값"] == "공종명" # 둠
sg = store.read("basis", "sg")["문서"]
assert sg["시트"][0]["칸"] == {"A2": {"값": "터파기"}} # 머리만 지우고 내용은 둠
assert store.read("structure", "rv")["문서"]["도번"] == "SD010101" # 짝 파일도 만듦
def test_다른_열_산출근거를_복사해_가져옴(client):
store.renumber()
got = client.get("/api/m02/templates/basis/rv").json()
got["문서"]["시트"][0]["칸"] = {"A1": {"식": "1+1"}}
client.put("/api/m02/templates/basis/rv", json=got)
r = client.post("/api/m02/basis/ms/copy", params={"from": "rv"})
assert r.status_code == 200
ms = client.get("/api/m02/templates/basis/ms").json()["문서"]
assert ms["시트"][0]["칸"] == {"A1": {"식": "1+1"}}
assert (ms["표번"], ms["열"]) == ("SC100101", "ms") # 번호 · 열은 받는 쪽 그대로
assert ms["계산값"] == SERVER # 서버가 다시 풂
assert client.post("/api/m02/basis/ms/copy", params={"from": "없는열"}).status_code == 404
def test_저장은_서버_계산값만_적음(client):
+85 -11
View File
@@ -1,6 +1,8 @@
"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사.
"""M02 구조물 도면(structure) — 새로 · 도번 SD+6자리 · 409 · 저장 · 지운 뒤 번호 ·
제목행 번호 매김 · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사.
산출근거는 따로 파일(basis) — `resources/tester/spreadsheet/test_spreadsheet_store.py`."""
import json
import shutil
import pytest
@@ -25,6 +27,10 @@ def client(tmp_path, monkeypatch):
return TestClient(app)
def _num(도번: str, 표번: str) -> dict:
return {"도번": 도번, "표번": 표번}
def test_새로_도번_자동_409_없는_열(client):
made = client.post("/api/m02/structures/pp_len")
assert made.status_code == 200
@@ -33,7 +39,7 @@ def test_새로_도번_자동_409_없는_열(client):
"구조물도면",
"structure",
"pp_len",
"구-01",
"SD010101",
)
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶)
assert doc["도면"]["format"] == 6
@@ -43,10 +49,14 @@ def test_새로_도번_자동_409_없는_열(client):
assert "산출근거" not in doc
assert client.get("/api/m02/templates/basis/pp_len").json()["문서"]["종류"] == "통합문서"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "구-02"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD020101"
assert client.post("/api/m02/structures/pp_len").status_code == 409
assert client.post("/api/m02/structures/없는열").status_code == 404
assert client.get("/api/m02/structures/numbers").json() == {"pp_len": "구-01", "rv": "구-02"}
assert client.post("/api/m02/structures/no").status_code == 404 # 글 열은 구조물 아님
assert client.get("/api/m02/structures/numbers").json() == {
"pp_len": _num("SD010101", "SC010101"),
"rv": _num("SD020101", "SC020101"),
}
rows = client.get("/api/m02/templates").json()
assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == {
("structure", "pp_len"),
@@ -68,15 +78,79 @@ def test_저장_뼈대_거름(client):
assert client.get("/api/m02/templates/structure/pp_len").json()["문서"] == doc
def test_지워도_번호를_당기지_않음(client):
def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
for column in ("pp_len", "rv", "ms"):
client.post(f"/api/m02/structures/{column}")
assert client.delete("/api/m02/templates/structure/pp_len").status_code == 200
assert client.get("/api/m02/structures/numbers").json() == {"rv": "구-02", "ms": "구-03"}
assert client.post("/api/m02/structures/fb").json()["문서"]["도번"] == "구-04"
# 가장 큰 번호를 지우면 그 번호부터 다시(있는 것 중 가장 큰 번호 + 1)
client.delete("/api/m02/templates/structure/fb")
assert client.post("/api/m02/structures/ec").json()["문서"]["도번"] == "구-04"
assert client.delete("/api/m02/templates/structure/rv").status_code == 200
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["rv"] == _num("", "SC020101") # 산출근거는 남아 번호를 쥠
assert numbers["ms"] == _num("SD030101", "SC030101") # 다른 열 번호는 그대로
# 같은 종류(관공) 새 열 = 그 무리 다음 번호 · 다시 만든 도면은 짝(산출근거) 번호를 이어 받음
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD010102"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD020101"
def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path):
"""옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 차례) · 내용 그대로."""
for column in ("no", "rv", "pv_l"):
doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"}
doc["도면"] = {"format": 6, "entities": [{"type": "line"}], "layers": []}
(tmp_path / "structure" / f"{column}.json").write_text(
json.dumps(doc, ensure_ascii=False), encoding="utf-8"
)
done = store.renumber()
assert done["지움"] == ["structure/no"] # 글 열(NO · 측점 · 비고)은 구조물 아님
numbers = client.get("/api/m02/structures/numbers").json()
assert set(numbers) == set(store._column_ids()) and "sta" not in numbers
expect = {
"rv": "010101", # 돌기슭막이 · (형태마다) · H=(높이마다)
"pv_b": "020101", # 콘크리트포장 · T=(두께마다) · B
"pv_l": "020102", # 같은 종류 · 분류1 · 다음 분류2
"fb": "060100", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00
"be": "140000", # 소단 — 분류 둘 다 없음
"h_rip_c": "270100", # 돌붙임 · 찰붙임
"h_rip_m": "270200", # 돌붙임 · 메붙임 — 분류1 다음 번호
}
for column, code in expect.items():
assert numbers[column] == _num(f"SD{code}", f"SC{code}"), column
assert store.read("structure", "rv")["문서"]["도면"]["entities"] == [{"type": "line"}]
assert store.renumber() == {"지움": [], "바꿈": [], "새로": []} # 두 번 돌려도 같음
def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(client):
store.renumber()
got = client.get("/api/m02/templates/table/구조물집계표").json()
table = got["문서"]
table["열"] = [c for c in table["열"] if c["id"] != "pv_a"]
new = {"id": "pv_x", "머리": ["콘크리트포장", "T=(두께마다)", "X"], "단위": "m", "꼴": "수"}
kind = {"id": "zz", "머리": ["새 구조물", None, None], "단위": "개소", "꼴": "수"}
text = {"id": "note2", "머리": ["메모", None, None], "단위": None, "꼴": "글"}
table["열"] += [new, kind, text]
saved = client.put(
"/api/m02/templates/table/구조물집계표", json={"판": got["판"], "문서": table}
)
assert saved.status_code == 200
numbers = client.get("/api/m02/structures/numbers").json()
assert "pv_a" not in numbers and "note2" not in numbers
for kind_ in ("structure", "basis"):
assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404
assert numbers["pv_x"] == _num("SD020106", "SC020106") # 지운 04 는 비워 둠 · 무리 다음
assert numbers["zz"] == _num("SD370000", "SC370000") # 새 종류 = 종류 다음 번호
def test_다른_열_도면을_복사해_가져옴(client):
store.renumber()
src = client.get("/api/m02/templates/structure/rv").json()
src["문서"]["도면"]["entities"] = [{"type": "circle"}]
client.put("/api/m02/templates/structure/rv", json=src)
r = client.post("/api/m02/structures/ms/copy", params={"from": "rv"})
assert r.status_code == 200
ms = client.get("/api/m02/templates/structure/ms").json()["문서"]
assert ms["도면"]["entities"] == [{"type": "circle"}]
assert (ms["도번"], ms["열"]) == ("SD100101", "ms") # 번호 · 열은 받는 쪽 그대로
assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD010101"
bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"})
assert bad.status_code == 404
def test_프로젝트_복사와_층_저장에_실림(client, tmp_path, monkeypatch):