Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fabc033c53 |
@@ -7,8 +7,8 @@ import { API_BASE_URL } from "@config/config_frontend";
|
||||
|
||||
/** 층 이름 — 서버와 같은 글 */
|
||||
export type Layer = "system" | "company" | "personal" | "project";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */
|
||||
export type Kind = "table" | "drawing";
|
||||
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure */
|
||||
export type Kind = "table" | "drawing" | "structure";
|
||||
|
||||
export interface TemplateInfo {
|
||||
종류: Kind;
|
||||
@@ -92,6 +92,32 @@ export const deleteLayerTemplate = (
|
||||
): Promise<{ ok: boolean }> =>
|
||||
call(`/layers/${layer}/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) ---
|
||||
* 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */
|
||||
|
||||
/** 구조물 도면 문서 — `도면` = 도면 양식과 같은 CAD 문서 · `산출근거` = 표 양식과 같은 표 문서 */
|
||||
export interface StructureDoc {
|
||||
양식: "구조물도면";
|
||||
종류: "structure";
|
||||
판: number;
|
||||
열: string;
|
||||
도번: string;
|
||||
도면: { entities: unknown[] } & Record<string, unknown>;
|
||||
산출근거: { 열: unknown[]; 줄: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 시스템 층 구조물 도면 목록 */
|
||||
export const listStructures = async (): Promise<TemplateInfo[]> =>
|
||||
(await listTemplates("system", null)).filter((t) => t.종류 === "structure");
|
||||
|
||||
/** 새로 — 도번 자동 · A1 도각을 깐 빈 도면 + 빈 산출근거 표 · 이미 있으면 StaleError */
|
||||
export const createStructure = (column: string): Promise<TemplateDoc> =>
|
||||
call(`/structures/${enc(column)}`, { method: "POST" });
|
||||
|
||||
/** `{열 id: 도번}` — 집계표 도면 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, string>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 --- */
|
||||
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
|
||||
const post = (path: string, body: object = {}): Promise<unknown> =>
|
||||
|
||||
@@ -32,6 +32,17 @@ def get_templates() -> list[dict]:
|
||||
return _call(store.list_all)
|
||||
|
||||
|
||||
@router.get("/structures/numbers")
|
||||
def get_structure_numbers() -> dict:
|
||||
return _call(store.structure_numbers)
|
||||
|
||||
|
||||
@router.post("/structures/{column}")
|
||||
def post_structure(column: str) -> dict:
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · A1 도각 + 빈 산출근거 표 · 이미 있으면 409."""
|
||||
return _call(store.create_structure, column)
|
||||
|
||||
|
||||
@router.get("/templates/{kind}/{name}")
|
||||
def get_template(kind: str, name: str) -> dict:
|
||||
return _call(store.read, kind, name)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{table,drawing}/<이름>.json`).
|
||||
"""M02 마스터 템플릿 — 시스템 층 저장소 (`resources/master_template/{종류}/<이름>.json`).
|
||||
|
||||
M01 `Store` 방식 — 판(파일 sha256 앞 16자) · 판이 다르면 409 · 원자 쓰기.
|
||||
권한은 등록하는 쪽(`main.py`)이 붙임.
|
||||
@@ -15,9 +15,15 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
|
||||
KINDS = ("table", "drawing") # 시험은 FOLDER 를 사본으로 바꿈
|
||||
KINDS = layers.KINDS # 시험은 FOLDER 를 사본으로 바꿈
|
||||
# 구조물 도면 = 구조물집계표 열 id 마다 하나(파일 이름 = 열 id) · 새로 만들 때 A1 도각을 깖
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
STRUCTURE_FRAME = "00_template_A1"
|
||||
_BASIS_HEAD = (("work", "공종"), ("spec", "규격"), ("detail", "산출 내역"), ("unit", "단위"))
|
||||
_NUMBER = re.compile(r"^구-(\d+)$")
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
|
||||
_LOCK = threading.Lock()
|
||||
@@ -66,10 +72,11 @@ def read(kind: str, name: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _check_skeleton(kind: str, doc: Any) -> None:
|
||||
"""빈 문서·뼈대 없는 문서는 거절 — 표는 열 목록 · 도면은 entities 목록."""
|
||||
key = "열" if kind == "table" else "entities"
|
||||
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
||||
raise StoreError(400, f"양식 문서에 「{key}」 목록이 없음 — 저장하지 않음")
|
||||
"""빈 문서·뼈대 없는 문서는 거절 — 층 저장과 같은 규칙(`layers.check_skeleton`)."""
|
||||
try:
|
||||
layers.check_skeleton(kind, doc)
|
||||
except ValueError as e:
|
||||
raise StoreError(400, str(e)) from e
|
||||
|
||||
|
||||
def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
@@ -92,3 +99,49 @@ def delete(kind: str, name: str, version: str | None = None) -> None:
|
||||
if version is not None and version_of(path.read_bytes()) != version:
|
||||
raise StoreError(409, {"stale": [name], "판": version_of(path.read_bytes())})
|
||||
path.unlink()
|
||||
|
||||
|
||||
# ── 구조물 도면 ───────────────────────────────────────
|
||||
|
||||
|
||||
def _structures() -> list[tuple[str, Any]]:
|
||||
folder = FOLDER / "structure"
|
||||
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 _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 create_structure(column: str) -> dict[str, Any]:
|
||||
"""열 id 하나의 구조물 도면 새로 — 도번 = 있는 것 중 가장 큰 번호 + 1 · 이미 있으면 409."""
|
||||
path = _path("structure", column)
|
||||
table = read("table", STRUCTURE_TABLE)["문서"]
|
||||
if column not in {col.get("id") for col in table.get("열", []) if isinstance(col, dict)}:
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 열 「{column}」")
|
||||
frame = read("drawing", STRUCTURE_FRAME)["문서"]
|
||||
basis = [{"id": key, "머리": [label], "단위": None, "꼴": "글"} for key, label in _BASIS_HEAD]
|
||||
basis.append({"id": "qty", "머리": ["수량(m당)"], "단위": None, "꼴": "수"})
|
||||
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}",
|
||||
"도면": frame,
|
||||
"산출근거": {"양식": "산출근거", "종류": "표", "판": 1, "열": basis, "줄": []},
|
||||
}
|
||||
atomic_write_json(path, doc)
|
||||
return read("structure", column)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest.
|
||||
|
||||
층 넷 (PLAN 10-5):
|
||||
system `resources/master_template/{table,drawing}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing}/`
|
||||
system `resources/master_template/{table,drawing,structure}/<이름>.json` (git)
|
||||
company `storage/{회사}/templates/{table,drawing,structure}/`
|
||||
personal `storage/{회사}/{사용자}/templates/{table,drawing,structure}/`
|
||||
project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure}/`
|
||||
+ 초기 사본 `templates/_initial/`
|
||||
|
||||
- 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`.
|
||||
@@ -26,7 +26,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
KINDS = ("table", "drawing")
|
||||
KINDS = ("table", "drawing", "structure")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -162,13 +162,25 @@ def read_template(layer_dir: str | Path, kind: str, name: str) -> dict[str, Any]
|
||||
}
|
||||
|
||||
|
||||
_SKELETON = {
|
||||
"table": ("열",),
|
||||
"drawing": ("entities",),
|
||||
"structure": ("도면.entities", "산출근거.열"),
|
||||
}
|
||||
|
||||
|
||||
def check_skeleton(kind: str, document: Any) -> None:
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록(빈 `{}` 저장 막기)."""
|
||||
key = {"table": "열", "drawing": "entities"}.get(kind)
|
||||
"""뼈대 없는 문서는 거절 — 표 = `열` 목록 · 도면 = `entities` 목록 ·
|
||||
구조물 도면 = `도면.entities` · `산출근거.열` 목록(빈 `{}` 저장 막기)."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("양식 문서는 JSON 객체여야 합니다.")
|
||||
if key and not isinstance(document.get(key), list):
|
||||
raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.")
|
||||
for key in _SKELETON.get(kind, ()):
|
||||
node: Any = document
|
||||
*parents, last = key.split(".")
|
||||
for part in parents:
|
||||
node = node.get(part) if isinstance(node, dict) else None
|
||||
if not isinstance(node, dict) or not isinstance(node.get(last), list):
|
||||
raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.")
|
||||
|
||||
|
||||
def write_template(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""M02 구조물 도면(structure) — 새로 · 도번 자동 · 409 · 저장 · 지운 뒤 번호 · 프로젝트 복사."""
|
||||
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
|
||||
REAL = store.FOLDER
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
for kind in store.KINDS:
|
||||
(tmp_path / kind).mkdir()
|
||||
shutil.copy(REAL / "table/구조물집계표.json", tmp_path / "table")
|
||||
shutil.copy(REAL / "drawing/00_template_A1.json", tmp_path / "drawing")
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_새로_도번_자동_409_없는_열(client):
|
||||
made = client.post("/api/m02/structures/pp_len")
|
||||
assert made.status_code == 200
|
||||
doc = made.json()["문서"]
|
||||
assert (doc["양식"], doc["종류"], doc["열"], doc["도번"]) == (
|
||||
"구조물도면",
|
||||
"structure",
|
||||
"pp_len",
|
||||
"구-01",
|
||||
)
|
||||
assert (
|
||||
doc["도면"]["entities"] and doc["도면"] == store.read("drawing", "00_template_A1")["문서"]
|
||||
)
|
||||
basis = doc["산출근거"]
|
||||
assert [c["머리"][0] for c in basis["열"]] == ["공종", "규격", "산출 내역", "단위", "수량(m당)"]
|
||||
assert basis["줄"] == [] and basis["종류"] == "표"
|
||||
|
||||
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "구-02"
|
||||
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"}
|
||||
rows = client.get("/api/m02/templates").json()
|
||||
assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == {
|
||||
("structure", "pp_len"),
|
||||
("structure", "rv"),
|
||||
}
|
||||
|
||||
|
||||
def test_저장_뼈대_거름(client):
|
||||
got = client.post("/api/m02/structures/pp_len").json()
|
||||
doc = got["문서"]
|
||||
doc["산출근거"]["줄"] = [{"id": "r1", "값": {"work": "관 부설"}}]
|
||||
saved = client.put("/api/m02/templates/structure/pp_len", json={"판": got["판"], "문서": doc})
|
||||
assert saved.status_code == 200 and saved.json()["판"] != got["판"]
|
||||
for bad in ({}, {**doc, "도면": {}}, {**doc, "산출근거": {"열": "x"}}, {**doc, "도면": []}):
|
||||
r = client.put(
|
||||
"/api/m02/templates/structure/pp_len", json={"판": saved.json()["판"], "문서": bad}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert client.get("/api/m02/templates/structure/pp_len").json()["문서"] == doc
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_프로젝트_복사와_층_저장에_실림(client, tmp_path, monkeypatch):
|
||||
doc = client.post("/api/m02/structures/pp_len").json()["문서"]
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
|
||||
project = tmp_path / "project"
|
||||
copied = layers.seed_project(project)
|
||||
assert "structure/pp_len" in copied["작업본"] and "structure/pp_len" in copied["초기"]
|
||||
work = layers.project_dir(project)
|
||||
assert layers.read_template(work, "structure", "pp_len")["문서"] == doc
|
||||
assert layers.read_template(layers.initial_dir(project), "structure", "pp_len")["문서"] == doc
|
||||
assert ("structure", "pp_len") in {(r["종류"], r["이름"]) for r in layers.list_templates(work)}
|
||||
layers.check_skeleton("structure", doc)
|
||||
with pytest.raises(ValueError):
|
||||
layers.check_skeleton("structure", {**doc, "산출근거": None})
|
||||
Reference in New Issue
Block a user