- summary_item 칸: StructureInstance · pipe_points 점 기록(options 밖) — 모양 검사 한 벌 common_util_summary_item(층 system·company·personal · owner null·{회사}·{회사}/{사용자} · key SS+6자리 · name) · 없으면 null(옛 파일 호환) · 관 요청 모양 틀림 400
- 저장 스냅샷 M02_Item_Snapshot: PUT /route/structures · PUT /drainage/pipe-points 뒤 지정 항목마다 templates/items/{layer}_{owner}_{key}/ 에 집계(SS 줄 + 열 정의) · 도면 · 면적 · 재료 · 자재조합 · 일위대가(UA 줄 + 담은 로직 줄) · manifest(원본 · 판 · 적용일 · 쓰는 구조물) — 있는 폴더는 다시 안 씀(원본이 바뀌어도 프로젝트 값 그대로) · 안 쓰는 폴더 지움 · 실패해도 저장 유지
- 채움 fill_table = 지정 항목 모드: 구조물이 고른 열(묶음)에만 · 미지정 · 복사본 없음은 안 셈 + 알림 · 스냅샷 열 정의 사용 · 회사 · 개인 항목은 열id@폴더 새 열 · 자동 맞춤은 fill_document 기본으로만 남음
- 관 시설 되붙이기가 summary_item 도 옮김
- [초기화]는 templates/ 를 안 건드림 · 복제 도구는 폴더 통째 복사 — 손댈 것 없음(시험으로 지킴)
- 시험: test_structures_summary_snapshot 17 · 옛 채움 시험은 자동 맞춤 길(fill_document)로 · 층 라우트 채움 시험은 새 규칙(미지정 관 = 줄 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
249 lines
12 KiB
Python
249 lines
12 KiB
Python
"""구조물 집계표 항목 지정 · 저장 스냅샷 · 채움 (PLAN 21-3 · 임시 저장소).
|
|
|
|
- `summary_item` 칸(구조물 · 관 지점) 모양 검사 · 옛 파일(칸 없음) 호환
|
|
- 저장 뒤 스냅샷: 지정 → 폴더 생김 · 원본(마스터) 바꿔도 프로젝트 값 그대로 · 지정 바꿈 → 옛 폴더
|
|
지움 · 해제 → 지움 · 회사 층 자리
|
|
- 채움(`fill_table`) = 지정 항목 모드 — 고른 열에만 · 미지정은 안 셈 · 스냅샷 열 정의 사용
|
|
- [초기화]는 `templates/` 를 안 건드림 · 프로젝트 복제 도구는 폴더를 통째 복사
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
|
from common_util import common_util_initial_snapshot as initial
|
|
from common_util.common_util_drainage_pipes import PipePoint, parse_pipe_points
|
|
from common_util.common_util_summary_item import parse_summary_item
|
|
from config import config_system
|
|
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
|
from M02_MasterTemplete import M02_Table_Fill as fill
|
|
from M02_MasterTemplete import M02_Template_Layers as layers
|
|
|
|
REAL = config_system.PROJECT_ROOT / "resources/master_template"
|
|
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
|
|
RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"}
|
|
PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"}
|
|
|
|
|
|
def _copy_layer(target: Path) -> None:
|
|
target.mkdir(parents=True)
|
|
for name in (layers.COMBO_FILE, "자재조합.json"):
|
|
shutil.copy2(REAL / name, target / name)
|
|
for kind in ("table", "structure", "area", "material"):
|
|
shutil.copytree(REAL / kind, target / kind)
|
|
|
|
|
|
@pytest.fixture
|
|
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
|
|
system = tmp_path / "master"
|
|
_copy_layer(system)
|
|
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
|
storage = tmp_path / "storage"
|
|
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
|
|
root = storage / "3" / "12" / "proj"
|
|
(root / "B05_Profile/route").mkdir(parents=True)
|
|
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
|
|
return {"system": system, "storage": storage, "root": root}
|
|
|
|
|
|
def _save(root: Path, structures: list[dict], points: list[dict]) -> dict[str, list[str]]:
|
|
"""화면 [저장] 두 곳(구조물 · 관 지점)이 쓴 뒤 서버가 맞추는 것과 같게."""
|
|
for index, item in enumerate(structures):
|
|
item.setdefault("structure_id", f"st{index}")
|
|
item.setdefault("placement", "interval" if "start_m" in item else "point")
|
|
(root / "B05_Profile/route/structures.json").write_text(
|
|
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
|
|
json.dumps({"points": points}, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
return snap.sync_snapshots(root)
|
|
|
|
|
|
def _dry(summary: dict | None) -> dict[str, Any]:
|
|
wall = {"type_id": "masonry_dry", "chainage_m": 85, "start_m": 80, "end_m": 90}
|
|
return {**wall, "options": {"height_m": 2.0, "length_m": 10}, "summary_item": summary}
|
|
|
|
|
|
def _wet() -> dict[str, Any]:
|
|
wall = {"type_id": "masonry_wet", "chainage_m": 120, "start_m": 120, "end_m": 130}
|
|
return {**wall, "options": {"height_m": 2.5, "length_m": 10}}
|
|
|
|
|
|
def _pipe(summary: dict | None) -> dict[str, Any]:
|
|
options = {"pipe_kind": "파형강관", "pipe_diameter_mm": "1000"}
|
|
return {"chainage_m": 40.0, "source": "user", "options": options, "summary_item": summary}
|
|
|
|
|
|
def _items(root: Path) -> Path:
|
|
return root / "templates" / "items"
|
|
|
|
|
|
# ── 칸 모양 ──────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
{"layer": "project", "owner": None, "key": "SS000001", "name": "x"},
|
|
{"layer": "system", "owner": "3", "key": "SS000001", "name": "x"},
|
|
{"layer": "company", "owner": "3/12", "key": "SS000001", "name": "x"},
|
|
{"layer": "personal", "owner": "3", "key": "SS000001", "name": "x"},
|
|
{"layer": "system", "owner": None, "key": "UA000001", "name": "x"},
|
|
{"layer": "system", "owner": None, "key": "SS000001", "name": ""},
|
|
{"layer": "system", "owner": None, "key": "SS000001", "name": "x", "more": 1},
|
|
"SS000001",
|
|
],
|
|
)
|
|
def test_모양이_틀린_항목은_거절(bad):
|
|
with pytest.raises(ValueError):
|
|
parse_summary_item(bad)
|
|
with pytest.raises(ValueError):
|
|
StructureInstance(type_id="x", placement="point", chainage_m=1, summary_item=bad)
|
|
with pytest.raises(ValueError):
|
|
parse_pipe_points([{"chainage_m": 1.0, "summary_item": bad}])
|
|
|
|
|
|
def test_옛_파일은_칸_없음_그대로_읽고_씀():
|
|
item = StructureInstance(type_id="x", placement="point", chainage_m=1)
|
|
assert item.summary_item is None
|
|
point = parse_pipe_points([{"chainage_m": 10.0, "source": "user"}])[0]
|
|
assert point.summary_item is None and "summary_item" not in point.as_dict()
|
|
owned = {"layer": "personal", "owner": "3/12", "key": "SS000002", "name": "내 옹벽"}
|
|
again = parse_pipe_points([PipePoint(chainage_m=5.0, summary_item=owned).as_dict()])[0]
|
|
assert again.summary_item == owned
|
|
|
|
|
|
# ── 스냅샷 ───────────────────────────────────────────
|
|
|
|
|
|
def test_지정하면_폴더가_생기고_내용이_원본과_같음(world):
|
|
root = world["root"]
|
|
result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)])
|
|
assert result == {
|
|
"만듦": ["system_none_SS000011", "system_none_SS000022"],
|
|
"지움": [],
|
|
"못만듦": [],
|
|
}
|
|
folder = _items(root) / "system_none_SS000022"
|
|
got = {path.name for path in folder.iterdir()}
|
|
assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got
|
|
total = json.loads((folder / "집계.json").read_text(encoding="utf-8"))
|
|
assert total["줄"]["키"] == "SS000022" and total["열"]["id"] == "ms"
|
|
drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8"))
|
|
assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing
|
|
manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8"))
|
|
assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16
|
|
pipe = json.loads(
|
|
(_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8")
|
|
)
|
|
assert pipe["구조물"] == ["pipe@40.0"]
|
|
# 일위대가 UA 줄 + 담은 로직 줄(M01 시스템)
|
|
labor = json.loads(
|
|
(_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8")
|
|
)
|
|
assert labor["줄"]["키"] == "UA000036"
|
|
assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [
|
|
entry["로직"] for entry in labor["줄"]["담은로직"]
|
|
]
|
|
|
|
|
|
def test_원본을_바꿔도_프로젝트_값은_그대로(world):
|
|
root, system = world["root"], world["system"]
|
|
_save(root, [_dry(MS)], [])
|
|
before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
|
table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8"))
|
|
for column in table["열"]:
|
|
if column["id"] == "ms":
|
|
column["바인딩"]["머리틀"] = ["바뀐 돌쌓기", "{0}", "{1}"]
|
|
(system / "table/구조물집계표.json").write_text(
|
|
json.dumps(table, ensure_ascii=False), encoding="utf-8"
|
|
)
|
|
assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀
|
|
after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8")
|
|
assert after == before
|
|
doc = fill.fill_table(root, table)
|
|
heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")]
|
|
assert heads == ["돌쌓기"] # 채움도 스냅샷 열 정의
|
|
|
|
|
|
def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world):
|
|
root = world["root"]
|
|
_save(root, [_dry(MS), _dry(MS)], [])
|
|
manifest = _items(root) / "system_none_SS000022/manifest.json"
|
|
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"]
|
|
# 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦
|
|
result = _save(root, [_dry(RV), _dry(MS)], [])
|
|
assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == []
|
|
assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"]
|
|
# 해제 · 삭제
|
|
assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"]
|
|
assert list(_items(root).iterdir()) == []
|
|
|
|
|
|
def test_회사_층_항목은_회사_폴더에서_복사(world):
|
|
root = world["root"]
|
|
company = world["storage"] / "3" / "templates"
|
|
_copy_layer(company)
|
|
combo = json.loads((company / layers.COMBO_FILE).read_text(encoding="utf-8"))
|
|
for row in combo["줄"]:
|
|
if row["키"] == "SS000022":
|
|
row["분류3"] = "회사 것"
|
|
(company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8")
|
|
owned = {**MS, "layer": "company", "owner": "3"}
|
|
assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"]
|
|
total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8"))
|
|
assert total["줄"]["분류3"] == "회사 것"
|
|
# 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열
|
|
doc = fill.fill_table(
|
|
root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
|
)
|
|
ids = [c["id"] for c in doc["열"] if "@" in c["id"]]
|
|
assert ids == ["ms@company_3_SS000022|메쌓기|2.0"]
|
|
|
|
|
|
def test_원본에_없는_항목은_못만듦으로_남김(world):
|
|
gone = {**MS, "key": "SS999999"}
|
|
assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"]
|
|
|
|
|
|
# ── 채움 ─────────────────────────────────────────────
|
|
|
|
|
|
def test_채움은_지정한_열에만_미지정은_안_셈(world):
|
|
root = world["root"]
|
|
_save(root, [_dry(MS), _wet()], [_pipe(PIPE), {**_pipe(None), "chainage_m": 60.0}])
|
|
table = json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
|
doc = fill.fill_table(root, table, {40.0: 12.0, 60.0: 9.0})
|
|
values = {row["id"]: row["값"] for row in doc["줄"] if not row.get("고정")}
|
|
assert set(values) == {"s80.00~90.00", "s40.00"} # 미지정 옹벽 · 관은 줄도 없음
|
|
assert values["s80.00~90.00"]["ms|메쌓기|2.0"] == 10 # 메쌓기는 더함 출처(종류)로
|
|
assert values["s40.00"]["pp_len|파형강관|1000"] == 12
|
|
assert not any("찰쌓기" in c["id"] for c in doc["열"]) # 자동 맞춤 없음
|
|
assert "집계 항목 미지정 2건 — 표에 안 들어감" in doc["알림"]
|
|
|
|
|
|
def test_스냅샷이_없으면_알림(world):
|
|
root = world["root"]
|
|
_save(root, [_dry(MS)], [])
|
|
shutil.rmtree(_items(root))
|
|
doc = fill.fill_table(
|
|
root, json.loads((world["system"] / "table/구조물집계표.json").read_text("utf-8"))
|
|
)
|
|
assert "집계 항목 복사본 없음 1건 — [저장] 뒤 채워짐" in doc["알림"]
|
|
|
|
|
|
# ── 초기화 · 복제 ────────────────────────────────────
|
|
|
|
|
|
def test_초기화는_templates_를_안_건드리고_복제는_통째로():
|
|
assert not any(tree.startswith("templates") for tree in initial._FILE_TREES)
|
|
tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8")
|
|
assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함)
|