Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2

This commit is contained in:
2026-09-30 08:00:38 +09:00
14 changed files with 1428 additions and 304 deletions
+19 -11
View File
@@ -3,8 +3,8 @@
개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가
줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 —
단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` ·
`store.write` · `matset.create` · 항목 연결 `write_file`) — sub4 나무 길
(`POST /layers/{layer}/tree`)이 오면 그리로.
`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과
같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a).
권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫.
"""
@@ -19,24 +19,30 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json
#: 복사본 파일 → 층 양식 종류
PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material"))
LEVELS = 4 # 지금 집계표 제목행 층 수 — 26-2a 경로 모델이 오면 깊이 자유
def heads_of(path: list[str], name: str | None) -> list[str]:
"""경로 + 이름 → 제목행(`머리`) — 빈 칸은 뺌 · 넷을 넘으면 넷째 칸에 이어 붙임.
ponytail: 제목행 4층 한계 — 26-2a(sub4) 경로 모델이 오면 경로를 그대로 적음."""
"""경로 + 이름 → 항목 경로(깊이 자유 · 빈 칸은 뺌) — 머리 줄 수는 집계표 저장이 맞춤."""
full = [str(p).strip() for p in [*path, name or ""] if str(p or "").strip()]
if not full:
raise ValueError("새 항목의 경로 · 이름이 비어 있습니다.")
if len(full) > LEVELS:
full = [*full[: LEVELS - 1], " ".join(full[LEVELS - 1 :])]
return [*full, *[""] * (LEVELS - len(full))]
return full
def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]:
"""같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로
(나무는 같은 경로 두 항목을 안 둠)."""
taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)]
out, n = list(full), 2
while out in taken:
out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1
return out
def _new_matset(row: dict[str, Any], fallback: str) -> str:
@@ -73,10 +79,12 @@ def create_item(
new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used)
um_row = read_json(copy / UM_FILE) if copy else None
um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None
heads = _free_path(doc, heads)
column = {
**json.loads(json.dumps(base_col)),
"id": new_id,
"머리": heads,
"경로": heads,
"꼴": "수",
"단위": unit or base_col.get("단위") or base_row.get("단위"),
"일위대가": base_col.get("일위대가") or base_row.get("일위대가"),
@@ -85,7 +93,7 @@ def create_item(
column.pop("본뜬열", None)
if origin:
column["본뜬"] = origin
doc["열"] = [*doc.get("열", []), column]
tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게)
store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS
for file, kind in PART_KINDS if copy else ():
got = read_json(copy / file)
@@ -103,7 +111,7 @@ def create_item(
if isinstance(links, dict) and links.get("줄"):
inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]})
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in heads if h)}
return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)}
def personal_item(company: int, user: int, made: dict[str, Any]) -> dict[str, Any]:
@@ -0,0 +1,90 @@
"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a).
제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 ·
키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억).
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, 바인딩, 펼침}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 — 저장소는
`store.sync_combos` · `store.read_combos` 이름으로 이 파일을 부름.
"""
from __future__ import annotations
import json
from typing import Any
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
HEAD = "SS"
NOTE = (
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열"
)
def _row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
path = tree.path_of(col)
return {
"키": key,
"열": col["id"],
"경로": path,
"이름": tree.name_of(path),
**tree.old_fields(path),
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),
}
def sync() -> dict[str, Any]:
"""장부를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
path = store.root() / store.COMBO_FILE
with store._LOCK:
old = json.loads(path.read_bytes()) if path.is_file() else {}
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
taken = [int(k[len(HEAD) :]) for k in keys.values()]
after = max(old.get("다음", 1), max(taken, default=0) + 1)
numbers = store.structure_numbers()
rows = []
for col in store._structure_columns():
if col["id"] not in keys:
keys[col["id"]] = f"{HEAD}{after:06d}"
after += 1
rows.append(_row(keys[col["id"]], col, numbers.get(col["id"], {})))
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after}
if old.get("지운번호"):
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
doc["줄"] = rows
if doc != old:
atomic_write_json(path, doc)
return doc
def read() -> dict[str, Any]:
"""장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · `경로` 가 없던 옛 줄은 읽을 때
종류 · 분류1~3 에서 채움(빈 분류 건너뜀 · 파일은 다음 [저장] 때 새 모양)."""
path = store.root() / store.COMBO_FILE
doc = json.loads(path.read_bytes()) if path.is_file() else sync()
for row in doc.get("줄") or []:
if "경로" not in row:
row["경로"] = tree.row_path(row)
row["이름"] = tree.name_of(row["경로"])
return doc
def migrate_paths() -> bool:
"""옛 집계표 · 장부 → 경로 모양(열 `경로` · 머리 줄 수 · 장부 `경로` · `이름`) — 한 번 · 두 번
돌려도 같음. 바뀐 것이 없으면 파일을 안 씀 · 바꿨으면 참. 지금 층(`store.at`) 기준."""
got = store.read("table", store.STRUCTURE_TABLE)
if tree.normalize_table(got["문서"]) != got["문서"]:
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
return True
path = store.root() / store.COMBO_FILE
before = path.read_bytes() if path.is_file() else b""
sync()
return (path.read_bytes() if path.is_file() else b"") != before
@@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
_OWNER = "현장"
@@ -150,7 +151,7 @@ def branch_material_combo(
return matset.create(row)["키"]
heads = next((c for c in store._structure_columns() if c["id"] == column), {})
default = " ".join(h for h in store._heads(heads) if h) if heads else column
default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1)
name = name.strip() or str(was.get("이름") or default or column)
rows = matset.listing()["줄"]
return _branch(
@@ -22,6 +22,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
# 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`)
ITEM_FIELDS = (
"열",
"경로",
"이름",
"종류",
"분류1",
"분류2",
@@ -74,8 +76,9 @@ def _combo_names() -> dict[str, str]:
def items(folder: Path) -> list[dict[str, Any]]:
"""그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · `names` = 화면 이름(키 대신 · 없으면 null) —
"""그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합,
도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 ·
분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) —
일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 ·
도번 · 면적번 · 재료번 = 항목(열) 이름."""
ua = _combo_names()
@@ -84,7 +87,7 @@ def items(folder: Path) -> list[dict[str, Any]]:
um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]}
out = []
for r in rows:
head = " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k))
head = r.get("이름")
names = {
"일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None,
"자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None,
@@ -155,4 +158,4 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
atomic_write_json(store._path(kind, new_id), body)
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1)
return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)}
return {"key": ss["키"], "열": new_id, "이름": ss["이름"]}
@@ -13,6 +13,7 @@ from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`)
FILE = mmc.FILE
@@ -47,7 +48,7 @@ def listing() -> dict[str, Any]:
def _name(col: dict) -> str:
return " ".join(h for h in store._heads(col) if h) or col["id"]
return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1)
def one(key: str) -> dict[str, Any]:
@@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_MasterTemplete_Router import (
BranchBody,
@@ -44,6 +45,18 @@ class InputLinksBody(BaseModel):
줄: list[Any] = []
class TreeBody(BaseModel):
"""26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`)."""
op: str
경로: list[str] | None = None
이름: str | None = None
새경로: list[str] | None = None
키: str | None = None
단위: str | None = None
판: str
class CopyItemBody(BaseModel):
from_layer: Literal["system", "company"]
from_owner: str | None = None
@@ -195,6 +208,50 @@ def get_item_pills(
return _call(_root(session, layer, owner), inputs_store.pills, key, place)
# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ──
def _tree_view(folder: Path) -> dict[str, Any]:
"""나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디."""
with store.at(folder):
got = store.read("table", store.STRUCTURE_TABLE)
doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음
return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)}
@router.get("/layers/{layer}/tree")
def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)):
folder = _root(session, layer, owner)
return _call(folder, _tree_view, folder)
def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
"""집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결."""
got = store.read("table", store.STRUCTURE_TABLE)
if got["판"] != body.get("판"):
raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]})
ids = {str(r.get("키")): str(r.get("열")) for r in store.read_combos().get("줄", [])}
try:
doc, note = tree.apply(got["문서"], body, ids)
except tree.TreeError as e:
raise store.StoreError(e.status, e.detail) from e
store.write("table", store.STRUCTURE_TABLE, got["판"], doc)
return {**_tree_view(folder), "알림": note}
@router.post("/layers/{layer}/tree")
def post_tree(
layer: Layer,
body: TreeBody,
owner: str | None = None,
session: Session = Depends(verify_session),
):
"""폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409.
쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`)."""
folder = _root(session, layer, owner, write=True)
return _call(folder, _edit_tree, folder, body.model_dump())
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
+25 -100
View File
@@ -19,6 +19,7 @@ from typing import Any
from common_util import common_util_spreadsheet as spreadsheet
from common_util.common_util_json import atomic_write_json
from M02_MasterTemplete import M02_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template"
@@ -46,9 +47,9 @@ def at(folder: str | Path) -> Iterator[Path]:
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
# (둘 다 스프레드시트 통합문서)
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3]
# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 ·
# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장)
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 — 한 열의 세 번호는 같은 8자리 · 한 번 준 번호는
# 파일에 남아 안 바뀜(PLAN 13장) · 새 항목은 자리 뜻 없는 일련번호(PLAN 26-1 — 있던 번호는 그대로)
# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`)
STRUCTURE_TABLE = "구조물집계표"
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
IN_USE = {
@@ -65,7 +66,6 @@ PARTS = (
)
_KEY = {kind: key for kind, key, _, _ in PARTS}
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
_LEVELS = 4
_CODE = re.compile(r"^S[DAM](\d{8})$")
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
@@ -158,6 +158,8 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
path = _path(kind, name)
_check_skeleton(kind, doc)
origins = _take_origins(doc) if kind == "table" else {}
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
if kind in SHEETS:
doc = _server_values(doc)
with _LOCK:
@@ -228,9 +230,8 @@ def structure_numbers() -> dict[str, dict[str, str]]:
def _heads(col: dict[str, Any]) -> tuple[str | None, ...]:
"""제목행 네 층(종류 · 분류1 · 분류2 · 분류3) — 빈 층은 None."""
parts = [*(col.get("머리") or []), *[None] * _LEVELS][:_LEVELS]
return tuple((str(p).strip() if p is not None else "") or None for p in parts)
"""옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음)."""
return tuple(tree.old_fields(tree.path_of(col)).values())
def _structure_columns() -> list[dict[str, Any]]:
@@ -247,49 +248,16 @@ def _column_ids() -> list[str]:
return [col["id"] for col in _structure_columns()]
def _pick_code(
col: dict[str, Any],
taken: dict[str, str],
heads: dict[str, tuple],
retired: list[str] | None = None,
) -> str:
"""새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를
이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음
번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로.
`taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 ·
`retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦)."""
parts = _heads(col)
codes = [*taken.values(), *(retired or [])]
mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads]
def next_of(prefix: str) -> str:
at = slice(len(prefix), len(prefix) + 2)
found = [int(c[at]) for c in codes if c.startswith(prefix)]
return f"{max(found, default=0) + 1:02d}"
code = ""
for depth, head in enumerate(parts):
if depth and head is None:
code += "00"
continue
at = slice(2 * depth, 2 * depth + 2)
group = parts[: depth + 1]
same = (c[at] for h, c in mates if h[: depth + 1] == group and c.startswith(code))
leaf = depth == _LEVELS - 1
code += (None if leaf else next(same, None)) or next_of(code)
if code in codes:
code = code[:-2] + next_of(code[:-2])
return code
def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str:
"""새 항목 번호 — 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가운데 가장 큰 수 + 1 · 8자리)."""
codes = [int(c) for c in [*taken.values(), *(retired or [])] if str(c).isdigit()]
return f"{max(codes, default=0) + 1:08d}"
def _new_code(column: str) -> str:
"""이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
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}, _retired())
return taken[column] if column in taken else _serial(taken, _retired())
def _retired() -> list[str]:
@@ -447,7 +415,7 @@ def _move_basis() -> list[str]:
def renumber() -> dict[str, list[str]]:
"""한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로
(6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) ·
(6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) ·
구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음."""
moved = _move_basis()
gone = prune_removed_structures()
@@ -455,12 +423,11 @@ def renumber() -> dict[str, list[str]]:
made: list[str] = []
with _LOCK:
cols = _structure_columns()
heads = {c["id"]: _heads(c) for c in cols}
taken = _codes()
retired = _retired()
for col in cols:
if col["id"] not in taken:
taken[col["id"]] = _pick_code(col, taken, heads, retired)
taken[col["id"]] = _serial(taken, retired)
docs = {kind: dict(_docs(kind)) for kind in _KEY}
for col in cols:
cid = col["id"]
@@ -485,7 +452,7 @@ def _server_values(doc: Any) -> dict[str, Any]:
def backfill_missing_structures() -> list[str]:
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호).
"""구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호).
그 사이 남이 만들었으면 그대로 둠."""
made: list[str] = []
for cid in _column_ids():
@@ -493,61 +460,19 @@ def backfill_missing_structures() -> list[str]:
return made
# ── 구조물집계표조합 ─────────────────────
# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) ·
# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억)
# ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ─────────────────────
COMBO_FILE = layers.COMBO_FILE
_COMBO_HEAD = "SS"
_COMBO_NOTE = (
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀"
)
def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
kind, level1, level2, level3 = _heads(col)
return {
"키": key,
"열": col["id"],
"종류": kind,
"분류1": level1,
"분류2": level2,
"분류3": level3,
"단위": col.get("단위"),
"일위대가": col.get("일위대가"),
"자재조합": col.get("자재조합"),
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
"바인딩": col.get("바인딩"),
"펼침": col.get("펼침"),
}
def sync_combos() -> dict[str, Any]:
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
path = root() / COMBO_FILE
with _LOCK:
old = json.loads(path.read_bytes()) if path.is_file() else {}
keys = {row["열"]: row["키"] for row in old.get("줄", [])}
taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()]
after = max(old.get("다음", 1), max(taken, default=0) + 1)
numbers = structure_numbers()
rows = []
for col in _structure_columns():
if col["id"] not in keys:
keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}"
after += 1
rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {})))
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after}
if old.get("지운번호"):
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
doc["줄"] = rows
if doc != old:
atomic_write_json(path, doc)
return doc
"""조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름)."""
from M02_MasterTemplete import M02_MasterTemplete_Book as book
return book.sync()
def read_combos() -> dict[str, Any]:
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
path = root() / COMBO_FILE
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
"""조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`)."""
from M02_MasterTemplete import M02_MasterTemplete_Book as book
return book.read()
@@ -0,0 +1,303 @@
"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침.
항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름)
· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀)
이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 —
옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음.
나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐
돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기
(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수).
"""
from __future__ import annotations
import copy
import re
from typing import Any
MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3
_OLD_NAMES = ("종류", "분류1", "분류2", "분류3")
OPS = ("add-folder", "add-item", "rename", "move", "delete")
MAX_NAME = 60
_BAD = re.compile(r"[\x00-\x1f]")
class TreeError(Exception):
def __init__(self, status: int, detail: Any):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 경로 읽기 ─────────────────────────────────────────
def _clean(parts: Any) -> list[str]:
if not isinstance(parts, list):
return []
return [str(p).strip() for p in parts if p is not None and str(p).strip()]
def path_of(col: dict[str, Any]) -> list[str]:
"""열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`."""
heads = col.get("머리")
return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로"))
def row_path(row: dict[str, Any]) -> list[str]:
"""SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀)."""
got = _clean(row.get("경로"))
return got or _clean([row.get(k) for k in _OLD_NAMES])
def name_of(path: list[str]) -> str:
return " ".join(path)
def old_fields(path: list[str]) -> dict[str, str | None]:
"""옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음)."""
padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS]
return dict(zip(_OLD_NAMES, padded))
def is_item(col: Any) -> bool:
return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수"
def _items(doc: dict[str, Any]) -> list[dict[str, Any]]:
return [c for c in doc.get("열") or [] if is_item(c)]
# ── 집계표 맞춤 ───────────────────────────────────────
def _trimmed(heads: Any) -> list[Any]:
out = list(heads) if isinstance(heads, list) else []
while out and (out[-1] is None or not str(out[-1]).strip()):
out.pop()
return out
def normalize_table(doc: dict[str, Any]) -> dict[str, Any]:
"""저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼
채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) ·
`폴더` = 경로 목록(겹침 · 빈 것 뺌).
글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤."""
out = copy.deepcopy(doc)
cols = [c for c in out.get("열") or [] if isinstance(c, dict)]
depth = max(
[MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))]
+ [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)]
)
names = [str(n) for n in (out.get("층") or [])][:depth]
names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)]
out["층"] = names
for col in cols:
if is_item(col):
path = path_of(col)
col["경로"] = path
col["머리"] = [*path, *[None] * depth][:depth]
else:
col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth]
folders: list[list[str]] = []
for raw in out.get("폴더") or []:
path = _clean(raw)
if path and path not in folders:
folders.append(path)
out["폴더"] = folders
return out
def folders_of(doc: dict[str, Any]) -> list[list[str]]:
"""나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례)."""
out: list[list[str]] = []
for col in _items(doc):
path = path_of(col)
for n in range(1, len(path)):
if path[:n] not in out:
out.append(path[:n])
for path in doc.get("폴더") or []:
path = _clean(path)
if path and path not in out:
out.append(path)
return out
# ── 나무 고침 ─────────────────────────────────────────
def _name(value: Any, what: str = "이름") -> str:
text = str(value).strip() if isinstance(value, str) else ""
if not text or len(text) > MAX_NAME or _BAD.search(text):
raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀")
return text
def _path(value: Any, what: str = "경로") -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님")
return [_name(v, what) for v in value]
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
"""SS 키(또는 열 id)로 항목 열 — 없으면 404."""
cid = ids.get(str(key), str(key)) if key else None
col = next((c for c in _items(doc) if c["id"] == cid), None)
if col is None:
raise TreeError(404, f"없는 항목 「{key}」")
return col
def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]:
return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)]
def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None:
if path in _item_paths(doc, skip):
raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」")
def _set_path(col: dict[str, Any], path: list[str]) -> None:
col["경로"] = path
col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움
def _common(a: list[str], b: list[str]) -> int:
n = 0
while n < min(len(a), len(b)) and a[n] == b[n]:
n += 1
return n
def _place(doc: dict[str, Any], col: dict[str, Any]) -> None:
"""열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게.
항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝."""
cols = doc.setdefault("열", [])
path = path_of(col)
best, at = -1, None
for i, other in enumerate(cols):
if is_item(other):
share = _common(path, path_of(other))
if share >= best:
best, at = share, i
if at is None:
at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1
cols.insert(at + 1, col)
def _fresh_id(doc: dict[str, Any]) -> str:
used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)}
n = 1
while f"item{n}" in used:
n += 1
return f"item{n}"
def _under(path: list[str], folder: list[str]) -> bool:
return path[: len(folder)] == folder
def _folder(doc: dict[str, Any], where: list[str], what: str) -> None:
"""고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404."""
if not where:
raise TreeError(422, f"{what} 폴더 「경로」 가 없음")
if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)):
raise TreeError(404, f"없는 폴더 「{name_of(where)}」")
def apply(
doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None
) -> tuple[dict[str, Any], dict[str, Any]]:
"""나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때).
본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄."""
out = copy.deepcopy(doc)
ids = ids or {}
op = body.get("op")
if op not in OPS:
raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}")
where = _path(body.get("경로"))
key = body.get("키")
note: dict[str, Any] = {"op": op}
folders = out.setdefault("폴더", [])
if op == "add-folder":
made = [*where, _name(body.get("이름"))]
if made in folders_of(out) or made in _item_paths(out):
raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」")
folders.append(made)
note["경로"] = made
elif op == "add-item":
made = [*where, _name(body.get("이름"))]
_taken_item(out, made)
col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"}
col.update({"일위대가": None, "자재조합": None})
_set_path(col, made)
_place(out, col)
note.update({"경로": made, "열": col["id"]})
elif op == "rename":
new = _name(body.get("이름"))
if key:
col = _find(out, key, ids)
path = path_of(col)
moved = [*path[:-1], new]
_taken_item(out, moved, [col])
_set_path(col, moved)
note["경로"] = moved
else:
_folder(out, where, "바꿀")
moved = [*where[:-1], new]
if moved != where and (
moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out))
):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved)
note["경로"] = moved
elif op == "move":
to = _path(body.get("새경로"), "새경로")
if key:
col = _find(out, key, ids)
moved = [*to, path_of(col)[-1]]
_taken_item(out, moved, [col])
out["열"] = [c for c in out["열"] if c is not col]
_set_path(col, moved)
_place(out, col)
note["경로"] = moved
else:
_folder(out, where, "옮길")
if _under(to, where):
raise TreeError(422, "폴더를 제 안으로 옮길 수 없음")
moved = [*to, where[-1]]
if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)):
raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」")
_move_folder(out, where, moved, replace=True)
note["경로"] = moved
else: # delete
if key:
gone = [_find(out, key, ids)]
else:
_folder(out, where, "지울")
gone = [c for c in _items(out) if _under(path_of(c), where)]
out["폴더"] = [f for f in folders if not _under(_clean(f), where)]
out["열"] = [c for c in out["열"] if all(c is not g for g in gone)]
note["지운항목"] = [g["id"] for g in gone]
return out, note
def _move_folder(
doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False
) -> None:
"""폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 ·
옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김."""
moving = [c for c in _items(doc) if _under(path_of(c), old)]
if replace:
doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)]
for col in moving:
_set_path(col, [*new, *path_of(col)[len(old) :]])
if replace:
_place(doc, col)
doc["폴더"] = [
[*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f
for f in doc.get("폴더") or []
]
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,19 @@
{
"그룹": "구조물집계표조합",
"판": 1,
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀",
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열",
"다음": 52,
"줄": [
{
"키": "SS000001",
"열": "rv",
"경로": [
"돌기슭막이",
"메쌓기",
"1.2m",
"기초유"
],
"이름": "돌기슭막이 메쌓기 1.2m 기초유",
"종류": "돌기슭막이",
"분류1": "메쌓기",
"분류2": "1.2m",
@@ -72,6 +79,13 @@
{
"키": "SS000002",
"열": "c4",
"경로": [
"돌기슭막이",
"메쌓기",
"1.2m",
"기초무, 소단"
],
"이름": "돌기슭막이 메쌓기 1.2m 기초무, 소단",
"종류": "돌기슭막이",
"분류1": "메쌓기",
"분류2": "1.2m",
@@ -88,6 +102,13 @@
{
"키": "SS000003",
"열": "c1",
"경로": [
"돌기슭막이",
"메쌓기",
"1.5m",
"기초유"
],
"이름": "돌기슭막이 메쌓기 1.5m 기초유",
"종류": "돌기슭막이",
"분류1": "메쌓기",
"분류2": "1.5m",
@@ -104,6 +125,11 @@
{
"키": "SS000004",
"열": "c5",
"경로": [
"돌기슭막이",
"찰쌓기"
],
"이름": "돌기슭막이 찰쌓기",
"종류": "돌기슭막이",
"분류1": "찰쌓기",
"분류2": null,
@@ -120,6 +146,12 @@
{
"키": "SS000005",
"열": "pv_b",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"B"
],
"이름": "콘크리트포장 T=(두께마다) B",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "B",
@@ -154,6 +186,12 @@
{
"키": "SS000006",
"열": "pv_l",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"L"
],
"이름": "콘크리트포장 T=(두께마다) L",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "L",
@@ -188,6 +226,12 @@
{
"키": "SS000007",
"열": "pv_w",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"확폭"
],
"이름": "콘크리트포장 T=(두께마다) 확폭",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "확폭",
@@ -222,6 +266,12 @@
{
"키": "SS000008",
"열": "pv_a",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"A"
],
"이름": "콘크리트포장 T=(두께마다) A",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "A",
@@ -238,6 +288,12 @@
{
"키": "SS000009",
"열": "pv_jt",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"수축줄눈"
],
"이름": "콘크리트포장 T=(두께마다) 수축줄눈",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "수축줄눈",
@@ -254,6 +310,12 @@
{
"키": "SS000010",
"열": "c3",
"경로": [
"콘크리트포장",
"T=(두께마다)",
"새 분류2"
],
"이름": "콘크리트포장 T=(두께마다) 새 분류2",
"종류": "콘크리트포장",
"분류1": "T=(두께마다)",
"분류2": "새 분류2",
@@ -270,6 +332,12 @@
{
"키": "SS000011",
"열": "pp_len",
"경로": [
"관공",
"(관종·관경마다)",
"관매설"
],
"이름": "관공 (관종·관경마다) 관매설",
"종류": "관공",
"분류1": "(관종·관경마다)",
"분류2": "관매설",
@@ -307,6 +375,12 @@
{
"키": "SS000012",
"열": "pp_cp",
"경로": [
"관공",
"(관종·관경마다)",
"커플링밴드"
],
"이름": "관공 (관종·관경마다) 커플링밴드",
"종류": "관공",
"분류1": "(관종·관경마다)",
"분류2": "커플링밴드",
@@ -323,6 +397,12 @@
{
"키": "SS000013",
"열": "pg_in",
"경로": [
"관보호공",
"(형식마다)",
"유입"
],
"이름": "관보호공 (형식마다) 유입",
"종류": "관보호공",
"분류1": "(형식마다)",
"분류2": "유입",
@@ -371,6 +451,12 @@
{
"키": "SS000014",
"열": "bx_len",
"경로": [
"BOX암거",
"(폭×높이마다)",
"연장"
],
"이름": "BOX암거 (폭×높이마다) 연장",
"종류": "BOX암거",
"분류1": "(폭×높이마다)",
"분류2": "연장",
@@ -404,6 +490,12 @@
{
"키": "SS000015",
"열": "bx_wing",
"경로": [
"BOX암거",
"(폭×높이마다)",
"날개벽"
],
"이름": "BOX암거 (폭×높이마다) 날개벽",
"종류": "BOX암거",
"분류1": "(폭×높이마다)",
"분류2": "날개벽",
@@ -453,6 +545,11 @@
{
"키": "SS000016",
"열": "fb",
"경로": [
"세월교",
"(관경 × 련수마다)"
],
"이름": "세월교 (관경 × 련수마다)",
"종류": "세월교",
"분류1": "(관경 × 련수마다)",
"분류2": null,
@@ -490,6 +587,12 @@
{
"키": "SS000017",
"열": "fp_w",
"경로": [
"물넘이포장",
"T=(두께마다)",
"폭"
],
"이름": "물넘이포장 T=(두께마다) 폭",
"종류": "물넘이포장",
"분류1": "T=(두께마다)",
"분류2": "폭",
@@ -524,6 +627,12 @@
{
"키": "SS000018",
"열": "fp_l",
"경로": [
"물넘이포장",
"T=(두께마다)",
"길이"
],
"이름": "물넘이포장 T=(두께마다) 길이",
"종류": "물넘이포장",
"분류1": "T=(두께마다)",
"분류2": "길이",
@@ -558,6 +667,12 @@
{
"키": "SS000019",
"열": "od_len",
"경로": [
"횡단개거",
"(규격마다)",
"길이"
],
"이름": "횡단개거 (규격마다) 길이",
"종류": "횡단개거",
"분류1": "(규격마다)",
"분류2": "길이",
@@ -589,6 +704,12 @@
{
"키": "SS000020",
"열": "od_cnt",
"경로": [
"횡단개거",
"(규격마다)",
"개소"
],
"이름": "횡단개거 (규격마다) 개소",
"종류": "횡단개거",
"분류1": "(규격마다)",
"분류2": "개소",
@@ -629,6 +750,12 @@
{
"키": "SS000021",
"열": "rw",
"경로": [
"옹벽",
"(형식마다)",
"H=(높이마다)"
],
"이름": "옹벽 (형식마다) H=(높이마다)",
"종류": "옹벽",
"분류1": "(형식마다)",
"분류2": "H=(높이마다)",
@@ -666,6 +793,12 @@
{
"키": "SS000022",
"열": "ms",
"경로": [
"돌쌓기",
"(찰·메마다)",
"H=(높이마다)"
],
"이름": "돌쌓기 (찰·메마다) H=(높이마다)",
"종류": "돌쌓기",
"분류1": "(찰·메마다)",
"분류2": "H=(높이마다)",
@@ -713,6 +846,12 @@
{
"키": "SS000023",
"열": "sg",
"경로": [
"흙막이",
"(형태마다)",
"H=(높이마다)"
],
"이름": "흙막이 (형태마다) H=(높이마다)",
"종류": "흙막이",
"분류1": "(형태마다)",
"분류2": "H=(높이마다)",
@@ -750,6 +889,12 @@
{
"키": "SS000024",
"열": "bm_len",
"경로": [
"큰돌쌓기",
"(찰·메 · 돌 크기 · 높이마다)",
"길이"
],
"이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 길이",
"종류": "큰돌쌓기",
"분류1": "(찰·메 · 돌 크기 · 높이마다)",
"분류2": "길이",
@@ -790,6 +935,12 @@
{
"키": "SS000025",
"열": "bm_area",
"경로": [
"큰돌쌓기",
"(찰·메 · 돌 크기 · 높이마다)",
"면적"
],
"이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 면적",
"종류": "큰돌쌓기",
"분류1": "(찰·메 · 돌 크기 · 높이마다)",
"분류2": "면적",
@@ -806,6 +957,11 @@
{
"키": "SS000026",
"열": "ec",
"경로": [
"골막이",
"(형태마다)"
],
"이름": "골막이 (형태마다)",
"종류": "골막이",
"분류1": "(형태마다)",
"분류2": null,
@@ -837,6 +993,10 @@
{
"키": "SS000027",
"열": "be",
"경로": [
"소단"
],
"이름": "소단",
"종류": "소단",
"분류1": null,
"분류2": null,
@@ -856,6 +1016,10 @@
{
"키": "SS000028",
"열": "rf",
"경로": [
"대피소"
],
"이름": "대피소",
"종류": "대피소",
"분류1": null,
"분류2": null,
@@ -875,6 +1039,11 @@
{
"키": "SS000029",
"열": "c2",
"경로": [
"대피소",
"새 분류1"
],
"이름": "대피소 새 분류1",
"종류": "대피소",
"분류1": "새 분류1",
"분류2": null,
@@ -891,6 +1060,10 @@
{
"키": "SS000030",
"열": "wy",
"경로": [
"정차·작업장"
],
"이름": "정차·작업장",
"종류": "정차·작업장",
"분류1": null,
"분류2": null,
@@ -910,6 +1083,10 @@
{
"키": "SS000031",
"열": "ta",
"경로": [
"차돌림곳"
],
"이름": "차돌림곳",
"종류": "차돌림곳",
"분류1": null,
"분류2": null,
@@ -929,6 +1106,10 @@
{
"키": "SS000032",
"열": "gr_rail",
"경로": [
"가드레일"
],
"이름": "가드레일",
"종류": "가드레일",
"분류1": null,
"분류2": null,
@@ -951,6 +1132,10 @@
{
"키": "SS000033",
"열": "gr_curb",
"경로": [
"경계석"
],
"이름": "경계석",
"종류": "경계석",
"분류1": null,
"분류2": null,
@@ -973,6 +1158,10 @@
{
"키": "SS000034",
"열": "gr_sign",
"경로": [
"위험표지"
],
"이름": "위험표지",
"종류": "위험표지",
"분류1": null,
"분류2": null,
@@ -995,6 +1184,10 @@
{
"키": "SS000035",
"열": "mr",
"경로": [
"반사경"
],
"이름": "반사경",
"종류": "반사경",
"분류1": null,
"분류2": null,
@@ -1014,6 +1207,10 @@
{
"키": "SS000036",
"열": "bg",
"경로": [
"차단기"
],
"이름": "차단기",
"종류": "차단기",
"분류1": null,
"분류2": null,
@@ -1033,6 +1230,10 @@
{
"키": "SS000037",
"열": "ps",
"경로": [
"국가지점번호판"
],
"이름": "국가지점번호판",
"종류": "국가지점번호판",
"분류1": null,
"분류2": null,
@@ -1052,6 +1253,10 @@
{
"키": "SS000038",
"열": "cs",
"경로": [
"준공표지석"
],
"이름": "준공표지석",
"종류": "준공표지석",
"분류1": null,
"분류2": null,
@@ -1071,6 +1276,11 @@
{
"키": "SS000039",
"열": "etc",
"경로": [
"기타",
"(이름마다)"
],
"이름": "기타 (이름마다)",
"종류": "기타",
"분류1": "(이름마다)",
"분류2": null,
@@ -1102,6 +1312,10 @@
{
"키": "SS000040",
"열": "h_intake",
"경로": [
"취수정"
],
"이름": "취수정",
"종류": "취수정",
"분류1": null,
"분류2": null,
@@ -1118,6 +1332,11 @@
{
"키": "SS000041",
"열": "h_rip_c",
"경로": [
"돌붙임",
"찰붙임"
],
"이름": "돌붙임 찰붙임",
"종류": "돌붙임",
"분류1": "찰붙임",
"분류2": null,
@@ -1134,6 +1353,11 @@
{
"키": "SS000042",
"열": "h_rip_m",
"경로": [
"돌붙임",
"메붙임"
],
"이름": "돌붙임 메붙임",
"종류": "돌붙임",
"분류1": "메붙임",
"분류2": null,
@@ -1150,6 +1374,10 @@
{
"키": "SS000043",
"열": "h_stone_ch",
"경로": [
"돌수로"
],
"이름": "돌수로",
"종류": "돌수로",
"분류1": null,
"분류2": null,
@@ -1166,6 +1394,10 @@
{
"키": "SS000044",
"열": "h_stone_bed",
"경로": [
"돌조공"
],
"이름": "돌조공",
"종류": "돌조공",
"분류1": null,
"분류2": null,
@@ -1182,6 +1414,10 @@
{
"키": "SS000045",
"열": "h_pv_rail",
"경로": [
"포장 난간"
],
"이름": "포장 난간",
"종류": "포장 난간",
"분류1": null,
"분류2": null,
@@ -1198,6 +1434,10 @@
{
"키": "SS000046",
"열": "h_pg_rail",
"경로": [
"관보호공 안전난간"
],
"이름": "관보호공 안전난간",
"종류": "관보호공 안전난간",
"분류1": null,
"분류2": null,
@@ -1214,6 +1454,10 @@
{
"키": "SS000047",
"열": "h_soil_ditch",
"경로": [
"수로형 토사개거"
],
"이름": "수로형 토사개거",
"종류": "수로형 토사개거",
"분류1": null,
"분류2": null,
@@ -1230,6 +1474,10 @@
{
"키": "SS000048",
"열": "h_ford_basin",
"경로": [
"물넘이 집수부"
],
"이름": "물넘이 집수부",
"종류": "물넘이 집수부",
"분류1": null,
"분류2": null,
@@ -1246,6 +1494,10 @@
{
"키": "SS000049",
"열": "h_rockfall",
"경로": [
"낙석방지책"
],
"이름": "낙석방지책",
"종류": "낙석방지책",
"분류1": null,
"분류2": null,
@@ -1262,6 +1514,10 @@
{
"키": "SS000050",
"열": "h_break",
"경로": [
"콘크리트·포장 깨기"
],
"이름": "콘크리트·포장 깨기",
"종류": "콘크리트·포장 깨기",
"분류1": null,
"분류2": null,
@@ -1278,6 +1534,10 @@
{
"키": "SS000051",
"열": "h_waste",
"경로": [
"폐기물처리"
],
"이름": "폐기물처리",
"종류": "폐기물처리",
"분류1": null,
"분류2": null,
@@ -67,13 +67,14 @@ def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tm
def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client):
made1 = client.post("/api/m02/structures/rv").json()["문서"]
made2 = client.post("/api/m02/structures/pv_b").json()["문서"]
assert made1["도번"] == "SD01010100" and made2["도번"] == "SD02010100"
# 새 항목 번호 = 자리 뜻 없는 일련번호(PLAN 26-1)
assert made1["도번"] == "SD00000001" and made2["도번"] == "SD00000002"
for kind, key, head in (("area", "면적번", "SA"), ("material", "재료번", "SM")):
doc = client.get(f"/api/m02/templates/{kind}/pv_b").json()["문서"]
assert doc[key] == f"{head}02010100"
assert doc[key] == f"{head}00000002"
assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["pv_b"] == {"도번": "SD02010100", "면적번": "SA02010100", "재료번": "SM02010100"}
assert numbers["pv_b"] == {"도번": "SD00000002", "면적번": "SA00000002", "재료번": "SM00000002"}
@pytest.mark.parametrize(
@@ -89,7 +90,7 @@ def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind,
assert r.status_code == 200
ms = client.get(f"/api/m02/templates/{kind}/ms").json()["문서"]
assert (ms["시트"][0]["칸"], ms["연결"]) == ({"A1": {"식": "1+1"}}, LINKS)
assert (ms[key], ms["열"]) == (f"{head}05010100", "ms") # 번호 · 열은 받는 쪽 그대로
assert (ms[key], ms["열"]) == (f"{head}00000010", "ms") # 번호 · 열은 받는 쪽 그대로
assert ms["계산값"] == SERVER # 서버가 다시 풂
assert client.post(f"/api/m02/{kind}/ms/copy", params={"from": "없는열"}).status_code == 404
+35 -32
View File
@@ -1,5 +1,6 @@
"""M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 ·
제목행 번호 매김(분류3 까지) · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사.
번호 매김(새 항목 = 자리 뜻 없는 일련번호 · PLAN 26-1) · 집계표 저장 때 만들기/지우기 ·
복사 가져오기 · 프로젝트 복사.
면적산출(area) · 재료산출(material)은 따로 파일 —
`resources/tester/spreadsheet/test_spreadsheet_store.py`."""
@@ -75,7 +76,7 @@ def test_새로_도번_자동_409_없는_열(client):
"구조물도면",
"structure",
"pp_len",
"SD01010100",
"SD00000001",
)
# 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶)
assert doc["도면"]["format"] == 6
@@ -86,13 +87,13 @@ def test_새로_도번_자동_409_없는_열(client):
for kind in ("area", "material"):
assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002"
assert client.post("/api/m02/structures/pp_len").status_code == 409
assert client.post("/api/m02/structures/없는열").status_code == 404
assert client.post("/api/m02/structures/no").status_code == 404 # 글 열은 구조물 아님
assert client.get("/api/m02/structures/numbers").json() == {
"pp_len": _num("01010100"),
"rv": _num("02010100"),
"pp_len": _num("00000001"),
"rv": _num("00000002"),
}
rows = client.get("/api/m02/templates").json()
assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == {
@@ -118,7 +119,7 @@ def test_저장_뼈대_거름(client):
"/api/m02/templates/structure/pp_len", json={"판": now, "문서": {**doc, "도번": "SD9"}}
)
got = client.get("/api/m02/templates/structure/pp_len").json()["문서"]
assert got["도번"] == "SD01010100" # 번호는 서버 것 — 화면 값을 안 받음
assert got["도번"] == "SD00000001" # 번호는 서버 것 — 화면 값을 안 받음
def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
@@ -126,15 +127,15 @@ def test_지워도_번호를_당기지_않음_짝은_같은_번호(client):
client.post(f"/api/m02/structures/{column}")
assert client.delete("/api/m02/templates/structure/rv").status_code == 200
numbers = client.get("/api/m02/structures/numbers").json()
assert numbers["rv"] == {**_num("02010100"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠
assert numbers["ms"] == _num("03010100") # 다른 열 번호는 그대로
# 같은 종류(관공) 새 열 = 그 무리 다음 번호 · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD01010200"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100"
assert numbers["rv"] == {**_num("00000002"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠
assert numbers["ms"] == _num("00000003") # 다른 열 번호는 그대로
# 새 열 = 다음 일련번호(자리 뜻 없음) · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음
assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD00000004"
assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD00000002"
def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path):
"""옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) ·
def test_번호_없는_열은_열_차례로_일련번호_한_번_옮김(client, tmp_path):
"""옛 구-NN 파일 · 글 열 파일 → 새 번호(번호 없는 열 = 열 차례 일련번호 · 자리 뜻 없음) ·
내용 그대로."""
for column in ("no", "rv", "pv_l"):
doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"}
@@ -146,16 +147,16 @@ def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path):
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": "01010100", # 돌기슭막이 · (형태마다) · H=(높이마다) · 분류3 없음 = 00
"c1": "01010200", # 같은 종류 · 분류1 · 다음 분류2
"pv_b": "02010100", # 콘크리트포장 · T=(두께마다) · B
"pv_l": "02010200",
"pp_len": "03010100",
"fb": "04010000", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00
"be": "06000000", # 소단 — 분류 모두 없음
"h_rip_c": "07010000", # 돌붙임 · 찰붙임
"h_rip_m": "07020000", # 돌붙임 · 메붙임 — 분류1 다음 번호
expect = { # 구조물 열 차례 그대로 1 부터(제목행 자리 뜻 없음)
"rv": "00000001",
"c1": "00000002",
"pv_b": "00000003",
"pv_l": "00000004",
"pp_len": "00000007",
"fb": "00000009",
"be": "00000011",
"h_rip_c": "00000012",
"h_rip_m": "00000013",
}
for column, code in expect.items():
assert numbers[column] == _num(code), column
@@ -182,24 +183,26 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c
assert "pv_a" not in numbers and "note2" not in numbers
for kind_ in ("structure", "area", "material"):
assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404
assert numbers["pv_x"] == _num("02010500") # 지운 pv_a(03)는 비워 둠 · 무리 다음
assert numbers["zz"] == _num("08000000") # 새 종류 = 종류 다음 번호
assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호
# 새 열 = 열 차례로 다음 일련번호 · 지운 pv_a(5) 는 비워 둠(다른 번호를 안 당김)
assert numbers["pv_x"] == _num("00000014")
assert numbers["zz"] == _num("00000015")
assert numbers["pv_y"] == _num("00000016")
assert numbers["pv_b"] == _num("00000003") # 있던 번호는 그대로
def test_지운_열_번호는_다음_저장에도_다시_안_줌(client):
"""무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음."""
"""가장 큰 번호 열(h_rip_m 13)을 지워 저장한 뒤 새 열을 더해도 13 을 다시 안 받음."""
store.renumber()
url = "/api/m02/templates/table/구조물집계표"
got = client.get(url).json()
got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"]
got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "h_rip_m"]
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"]
assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["00000013"]
got = client.get(url).json()
new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"}
got["문서"]["열"].append(new)
assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500")
assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("00000014")
def test_다른_열_도면을_복사해_가져옴(client):
@@ -211,8 +214,8 @@ def test_다른_열_도면을_복사해_가져옴(client):
assert r.status_code == 200
ms = client.get("/api/m02/templates/structure/ms").json()["문서"]
assert ms["도면"]["entities"] == [{"type": "circle"}]
assert (ms["도번"], ms["열"]) == ("SD05010100", "ms") # 번호 · 열은 받는 쪽 그대로
assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD01010100"
assert (ms["도번"], ms["열"]) == ("SD00000010", "ms") # 번호 · 열은 받는 쪽 그대로
assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD00000001"
bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"})
assert bad.status_code == 404
+2 -1
View File
@@ -10,6 +10,7 @@ import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M02_MasterTemplete import M02_MasterTemplete_Book as book
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
@@ -97,7 +98,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
def test_SS_머리글은_키대장과_안_겹침():
ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
assert "SS" not in ledger["다음"]
assert store._COMBO_HEAD == "SS"
assert book.HEAD == "SS" # 장부는 `M02_MasterTemplete_Book`(26-2a 나눔)
def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch):
+261
View File
@@ -0,0 +1,261 @@
"""M02 집계 항목 폴더 구조(PLAN 26-1 · 26-2a) — 경로 · 옛 자료 읽기 · 일련번호 · 나무 고침 · 권한.
① 옛 마스터(실제 `resources/master_template` · 읽기만) 51 항목이 경로로 읽힘 — 개수 · 이름(옛 종류 ·
분류1~3 이음과 같음) · 키 차례.
② 나무 고침은 임시 storage(회사 · 개인 층)와 견본 시스템 집계표로만 — 빈 층 첫 폴더 · 항목 · 깊이
1 · 2 · 5 · 이름 바꾸기 · 옮기기 · 지우기(세 파일 · 입력 연결 같이) · 마지막 항목 · 판 409 ·
422 · 404 · 권한 403 · 표 저장과 한 줄기(머리를 고치면 경로가 따라옴) · 번호 일련 · 안 되돌림.
"""
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.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_MasterTemplete_Book as book
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_MasterTemplete_Tree as tree
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.test_m02_structure import write_sample_table
TABLE = "table/구조물집계표"
MASTER = Path(__file__).resolve().parents[2] / "resources" / "master_template"
def _app(session: dict[str, Any]) -> TestClient:
app = FastAPI()
app.include_router(layers_router.router)
app.include_router(items_router.router)
app.dependency_overrides[verify_session] = lambda: session
return TestClient(app)
# ── ① 옛 마스터 — 읽기만 ─────────────────────────────
def test_옛_마스터_51_항목이_경로로_읽힘():
raw = json.loads((MASTER / store.COMBO_FILE).read_bytes())["줄"]
old = [
" ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) for r in raw
]
client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False})
rows = client.get("/api/m02/items").json()
assert len(rows) == len(raw) == 51
assert [r["key"] for r in rows] == [r["키"] for r in raw]
assert [r["이름"] for r in rows] == old
assert all(r["경로"] and r["이름"] == " ".join(r["경로"]) for r in rows)
got = client.get("/api/m02/layers/system/tree").json()
assert len(got["items"]) == 51 and got["판"]
assert ["돌기슭막이"] in got["폴더"] and ["돌기슭막이", "메쌓기"] in got["폴더"]
# 옛 줄(경로 없음)도 종류 · 분류1~3 에서 경로로(빈 분류 건너뜀)
assert tree.row_path({"종류": "대피소", "분류1": None, "분류2": "", "분류3": "x"}) == [
"대피소",
"x",
]
def test_옮기기_함수는_두_번_돌려도_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
for kind in store.KINDS:
(tmp_path / kind).mkdir()
for name in ("table/구조물집계표.json", store.COMBO_FILE):
(tmp_path / name).write_bytes((MASTER / name).read_bytes())
monkeypatch.setattr(store, "FOLDER", tmp_path)
before = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"]
store.backfill_missing_structures() # 사본에 세 파일(번호) — 옮기기가 번호를 안 바꾸는지 봄
assert book.migrate_paths() is True
assert book.migrate_paths() is False
after = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"]
assert [r["키"] for r in after] == [r["키"] for r in before]
assert all(r["경로"] == tree.row_path({**r, "경로": None}) for r in after)
table = json.loads((tmp_path / "table/구조물집계표.json").read_bytes())
assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == []
assert all(c["경로"] == tree.path_of(c) for c in table["열"] if tree.is_item(c))
# ── ② 나무 고침 — 임시 storage ──────────────────────────
@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)
store.renumber()
storage = tmp_path / "storage"
storage.mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
return {"client": _app(session), "session": session, "storage": storage, "system": system}
def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient:
world["session"].update(role=role, user_id=user)
return world["client"]
class Tree:
"""한 층 나무를 판을 따라가며 고침."""
def __init__(self, client: TestClient, layer: str):
self.client, self.layer = client, layer
self.url = f"/api/m02/layers/{layer}/tree"
self.now = client.get(self.url).json()
def do(self, want: int = 200, **body: Any) -> dict[str, Any]:
res = self.client.post(self.url, json={"판": self.now["판"], **body})
assert res.status_code == want, res.text
if want == 200:
self.now = res.json()
assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]}
return res.json()
def item(self, path: list[str]) -> dict[str, Any]:
return next(r for r in self.now["items"] if r["경로"] == path)
def paths(self) -> list[list[str]]:
return [r["경로"] for r in self.now["items"]]
def _numbers(root: Path, cid: str) -> set[str]:
out = set()
for kind, key, _, _ in store.PARTS:
doc = json.loads((root / kind / f"{cid}.json").read_bytes())
out.add(doc[key][2:])
return out
def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(world):
t = Tree(_as(world), "personal")
root = world["storage"] / "7" / "42" / "templates"
assert (t.now["폴더"], t.now["items"]) == ([], []) # 빈 층
t.do(op="add-folder", 경로=[], 이름="돌쌓기")
assert t.now["폴더"] == [["돌쌓기"]] and t.now["items"] == []
t.do(op="add-item", 경로=[], 이름="소단", 단위="m") # 깊이 1
t.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") # 깊이 2
t.do(op="add-item", 경로=["a", "b", "c", "d"], 이름="e") # 깊이 5(폴더는 저절로)
assert t.paths() == [["소단"], ["돌쌓기", "찰쌓기"], ["a", "b", "c", "d", "e"]]
deep = t.item(["a", "b", "c", "d", "e"])
assert deep["이름"] == "a b c d e"
assert (deep["종류"], deep["분류1"], deep["분류2"], deep["분류3"]) == ("a", "b", "c", "d")
assert t.item(["소단"])["단위"] == "m"
table = json.loads((root / "table/구조물집계표.json").read_bytes())
assert table["층"] == ["종류", "분류1", "분류2", "분류3", "분류4"] # 가장 깊은 경로에 맞춤
assert all(len(c["머리"]) == 5 for c in table["열"])
# 번호 = 자리 뜻 없는 일련번호 · 세 파일 같은 번호
codes = [_numbers(root, t.item(p)["열"]) for p in t.paths()]
assert codes == [{"00000001"}, {"00000002"}, {"00000003"}]
assert ["a", "b", "c", "d"] in t.now["폴더"] and ["돌쌓기"] in t.now["폴더"]
# 겹침 409 · 모양 422 · 없는 것 404
t.do(409, op="add-item", 경로=["돌쌓기"], 이름="찰쌓기")
t.do(409, op="add-folder", 경로=[], 이름="a")
for bad in (
{"op": "grow"},
{"op": "add-item", "이름": " "},
{"op": "add-item", "이름": "x" * 61},
):
t.do(422, **bad)
t.do(404, op="rename", 키="SS999999", 이름="x")
t.do(404, op="delete", 경로=["없는폴더"])
# 이름 바꾸기 — 항목(키 · 열 · 번호 그대로) · 폴더(아래 항목 모두)
key = t.item(["돌쌓기", "찰쌓기"])["key"]
t.do(op="rename", 키=key, 이름="메쌓기")
moved = t.item(["돌쌓기", "메쌓기"])
assert moved["key"] == key and _numbers(root, moved["열"]) == {"00000002"}
t.do(op="rename", 경로=["a"], 이름="A")
assert ["A", "b", "c", "d", "e"] in t.paths() and ["a"] not in t.now["폴더"]
t.do(409, op="rename", 경로=["A"], 이름="돌쌓기") # 있는 폴더로 합치기 막음
# 옮기기 — 항목 · 폴더 · 제 안으로는 422
t.do(op="move", 키=t.item(["소단"])["key"], 새경로=["돌쌓기"])
t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"])
assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]]
t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"])
# 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지
gone = t.item(["돌쌓기", "소단"])
(root / "inputs").mkdir(exist_ok=True)
(root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8")
note = t.do(op="delete", 키=gone["key"])["알림"]
assert note["지운항목"] == [gone["열"]]
for part in ("structure", "area", "material", "inputs"):
assert not (root / part / f"{gone['열']}.json").is_file()
t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌
assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"00000004"}
t.do(op="delete", 경로=["돌쌓기", "b"]) # 깊은 항목이 빠지면 머리 줄 넷으로
table = json.loads((root / "table/구조물집계표.json").read_bytes())
assert table["층"] == ["종류", "분류1", "분류2", "분류3"]
t.do(op="delete", 경로=["돌쌓기"]) # 마지막 항목들까지
assert t.now["items"] == [] and t.now["폴더"] == []
assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == []
def test_판_409_권한_403(world):
admin = Tree(_as(world, "ADMIN"), "company")
stale = admin.now["판"]
admin.do(op="add-folder", 경로=[], 이름="회사폴더")
res = admin.client.post(admin.url, json={"판": stale, "op": "add-folder", "이름": "x"})
assert res.status_code == 409 and res.json()["detail"]["판"] == admin.now["판"]
user = _as(world) # 같은 회사 일반 사용자 — 읽기 200 · 고침 403
assert user.get("/api/m02/layers/company/tree").json()["폴더"] == [["회사폴더"]]
body = {"판": admin.now["판"], "op": "add-folder", "이름": "y"}
assert user.post("/api/m02/layers/company/tree", json=body).status_code == 403
assert user.get("/api/m02/layers/personal/tree?owner=7/9").status_code == 403 # 남의 개인
system = user.get("/api/m02/layers/system/tree").json()
body = {"판": system["판"], "op": "add-folder", "이름": "y"}
assert user.post("/api/m02/layers/system/tree", json=body).status_code == 403
sysadmin = Tree(_as(world, "SYSTEM_ADMIN"), "system")
sysadmin.do(op="add-item", 경로=["돌붙임"], 이름="줄붙임")
assert ["돌붙임", "줄붙임"] in sysadmin.paths()
def test_표_저장과_나무가_한_줄기(world):
"""집계표 표에서 머리를 고치면 경로가 따라오고, 나무 고침 뒤 표에도 같은 자료가 보임."""
admin = _as(world, "SYSTEM_ADMIN")
t = Tree(admin, "system")
rv = next(r for r in t.now["items"] if r["열"] == "rv")
url = f"/api/m02/layers/system/templates/{TABLE}"
got = admin.get(url).json()
col = next(c for c in got["문서"]["열"] if c["id"] == "rv")
col["머리"] = ["돌기슭막이", None, "찰쌓기", None] # 빈 칸은 건너뜀
assert admin.put(url, json=got).status_code == 200
t = Tree(admin, "system")
assert t.item(["돌기슭막이", "찰쌓기"])["key"] == rv["key"]
t.do(op="rename", 키=rv["key"], 이름="메쌓기")
table = admin.get(url).json()["문서"]
col = next(c for c in table["열"] if c["id"] == "rv")
assert col["경로"] == ["돌기슭막이", "메쌓기"] and col["머리"] == [
"돌기슭막이",
"메쌓기",
None,
None,
]
# 표에서 열을 지우면 나무에서도 빠지고 파일도 같이
table["열"] = [c for c in table["열"] if c["id"] != "rv"]
assert (
admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200
)
assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]]
assert not (world["system"] / "structure" / "rv.json").is_file()
def test_프로젝트에서_새_항목도_경로_그대로(world):
"""개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 ·
같은 경로면 「이름 (2)」."""
from M02_MasterTemplete import M02_Item_Personal as personal
folder = layers.personal_dir(7, 42)
made = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m")
t = Tree(_as(world), "personal")
assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"]
assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"]
assert made["이름"] == "가 나 다 라 마"