From 89fc6869622a2295a03b091ce609ba94abf710a1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 12:08:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=208=EC=9E=90=EB=A6=AC(=EB=B6=84=EB=A5=983)?= =?UTF-8?q?=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20SA=20=C2=B7?= =?UTF-8?q?=20=EC=9E=AC=EB=A3=8C=EC=82=B0=EC=B6=9C=20SM=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 번호 [종류][분류1][분류2][분류3] · 없는 층 00 · 12장 6자리는 끝에 00 을 붙여 읽음 - 양식 종류 basis → area(면적산출) · material(재료산출) 새로 · 집계표 저장 때 세 파일 자동 만들기/지우기 - 복사 길 POST /structures · /area · /material/{받는 열}/copy?from= · 알약 연결은 문서와 같이 복사 - 번호 목록 {열 id: {도번, 면적번, 재료번}} · 저장 때 번호 칸은 서버 것 그대로 - renumber: basis/ → area/ 옮김(표번 → 면적번 · 시트 이름 면적산출) · 빠진 재료산출 만듦 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7 --- .../M02_MasterTemplete_Router.py | 18 +- .../M02_MasterTemplete_Router_Layers.py | 5 +- .../M02_MasterTemplete_Store.py | 359 ++++++++---------- M02_MasterTemplete/M02_Template_Layers.py | 15 +- .../spreadsheet/test_spreadsheet_store.py | 174 ++++----- resources/tester/test_m02_structure.py | 78 ++-- 6 files changed, 290 insertions(+), 359 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 68c2fadc..f6abafb7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -39,20 +39,24 @@ def get_structure_numbers() -> dict: @router.post("/structures/{column}") def post_structure(column: str) -> dict: - """구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 산출근거도 · 이미 있으면 409.""" + """구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409.""" return _call(store.create_structure, column) +# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로 @router.post("/structures/{column}/copy") def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict: - """다른 열 구조물 도면 내용을 가져옴 — 도번 · 열은 받는 쪽 그대로.""" - return _call(store.copy_structure, column, source) + return _call(store.copy_from, "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.post("/area/{column}/copy") +def post_area_copy(column: str, source: str = Query(alias="from")) -> dict: + return _call(store.copy_from, "area", column, source) + + +@router.post("/material/{column}/copy") +def post_material_copy(column: str, source: str = Query(alias="from")) -> dict: + return _call(store.copy_from, "material", column, source) @router.get("/templates/{kind}/{name}") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 8ae50715..ec54af44 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -261,8 +261,9 @@ async def save_layer_template( if kind == "table" and fill.is_fillable(document): # 채운 표가 와도 설계값은 안 받아 적음 — 양식 + 손 값만(5장 · 브라우저 값을 믿지 않음) document = fill.strip_design(document) - if kind == "basis": - # 산출근거 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음(브라우저 값 버림 · 5장 ②) + if kind in ("area", "material"): + # 면적산출 · 재료산출 계산값은 서버가 같은 TS 엔진을 Node 로 돌려 새로 적음 + # (브라우저 값 버림 · 5장 ②) try: document = await asyncio.to_thread(spreadsheet.with_server_values, document) except spreadsheet.RecalcError as error: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index a11dbe62..77612ea0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -20,19 +20,24 @@ 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 로 따로 한 파일(`basis/<열 id>.json` · 스프레드시트 통합문서) -# 번호 = 도번 SD · 표번 SC + 6자리 [종류][분류1][분류2](제목행 = 열 `머리` 세 층 · 없는 층 00) — -# 한 열의 두 번호는 같은 6자리 · 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 12장) +# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) — +# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/` +# (둘 다 스프레드시트 통합문서) +# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3] +# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 · +# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장) STRUCTURE_TABLE = "구조물집계표" -_BASIS_HEAD = ("공종", "규격", "산출 내역", "단위", "수량(m당)") -_BASIS_HEAD_STYLE = { - "굵게": True, - "가로": "center", - "세로": "center", - "테두리": {side: {"선": "thin"} for side in ("위", "아래", "왼", "오른")}, -} -_CODE = re.compile(r"^S[DC](\d{6})$") +# (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면 +PARTS = ( + ("structure", "도번", "SD", None), + ("area", "면적번", "SA", "면적산출"), + ("material", "재료번", "SM", "재료산출"), +) +_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]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties # of undefined (reading 'length')") · CAD 쪽도 없으면 새로 채우지만(2026-09-27 sub1 고침) @@ -94,18 +99,22 @@ def _check_skeleton(kind: str, doc: Any) -> None: def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: """`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀. - 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 산출근거를 번호와 같이 만들고 - 지운 열(또는 구조물이 아니게 된 열)의 두 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). - 산출근거(basis)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 - (브라우저 값 버림 · 5장 ②).""" + 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이 + 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). + 통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음 + (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. + 번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).""" path = _path(kind, name) _check_skeleton(kind, doc) - if kind == "basis": + if kind in SHEETS: doc = _server_values(doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): raise StoreError(409, {"stale": [name], "판": have}) + if have and kind in _KEY: + number = json.loads(path.read_bytes()).get(_KEY[kind]) + doc = {**doc, _KEY[kind]: number} if number else doc atomic_write_json(path, doc) info = _info(kind, path) if kind == "table" and name == STRUCTURE_TABLE: @@ -124,43 +133,45 @@ def delete(kind: str, name: str, version: str | None = None) -> None: path.unlink() -# ── 구조물 도면 ─────────────────────────────────────── +# ── 구조물 도면 · 면적산출 · 재료산출 ───────────────────── -def _structures() -> list[tuple[str, Any]]: - folder = FOLDER / "structure" +def _docs(kind: str) -> list[tuple[str, Any]]: + folder = FOLDER / kind return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] 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 + """문서의 번호 8자리 — 12장 6자리는 끝에 00 · 옛 `구-01` · `표-01` · 없음은 None.""" + text = str(doc.get(key) or "") if isinstance(doc, dict) else "" + if found := _CODE.match(text): + return found.group(1) + found = _OLD_CODE.match(text) + return found.group(1) + "00" if found else None 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, "도번"))}) + """`{열 id: 8자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번.""" + codes: dict[str, str] = {} + for kind, key, _, _ in reversed(PARTS): + codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))}) return codes def structure_numbers() -> dict[str, dict[str, str]]: - """`{열 id: {도번, 표번}}` — 집계표 도면 · 산출근거 줄 · 좌측 줄이 한 번에 받음 - (없는 쪽은 빈 글).""" + """`{열 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 "") + for kind, key, _, _ in PARTS: + for name, doc in _docs(kind): + row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS}) + row[key] = str(doc.get(key) 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 _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) def _structure_columns() -> list[dict[str, Any]]: @@ -178,32 +189,36 @@ def _column_ids() -> list[str]: 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) + """새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를 + 이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음 + 번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로. + `taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행.""" + parts = _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: + 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}" - 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 + 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 _new_code(column: str) -> str: - """이 열이 쓸 6자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" + """이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" taken = _codes() if column in taken: return taken[column] @@ -212,144 +227,72 @@ def _new_code(column: str) -> str: return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}) -def create_structure(column: str) -> dict[str, Any]: - """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 6자리(산출근거와 같은 6자리) · 이미 있으면 409. - 산출근거 파일이 없으면 빈 통합문서도 같이 만듦.""" - path = _path("structure", column) - if column not in _column_ids(): - raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") - with _LOCK: - if path.is_file(): - raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") - 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]: - """빈 구조물 도면(도번은 부르는 쪽이 붙임) — 도각 없음 · 기본 도면층 하나.""" +def _blank(kind: str, column: str) -> dict[str, Any]: + """빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 / + 면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음).""" + if kind == "structure": + return { + "양식": "구조물도면", + "종류": "structure", + "판": 1, + "열": column, + "도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]}, + } + sheet = next(name for k, _, _, name in PARTS if k == kind) return { - "양식": "구조물도면", - "종류": "structure", + "종류": "통합문서", "판": 1, "열": column, - "도면": {"format": 6, "entities": [], "layers": [dict(_BLANK_LAYER)]}, + "서식": [{}], + "시트": [{"id": "s1", "이름": sheet, "칸": {}}], + "활성": "s1", } -def copy_structure(column: str, source: str) -> dict[str, Any]: - """다른 열 구조물 도면 내용을 가져옴 — 도번 · 열은 받는 쪽 그대로(두 열은 따로 움직임).""" - return _copy("structure", column, source, ("열", "도번")) +def _ensure(kind: str, column: str) -> bool: + """그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로.""" + path = _path(kind, column) + head = next(h for k, _, h, _ in PARTS if k == kind) + with _LOCK: + if path.is_file(): + return False + atomic_write_json(path, {**_blank(kind, column), _KEY[kind]: head + _new_code(column)}) + return True -def copy_basis(column: str, source: str) -> dict[str, Any]: - """다른 열 산출근거 내용을 가져옴 — 표번 · 열은 받는 쪽 그대로 · 계산값은 서버가 다시 풂.""" - return _copy("basis", column, source, ("열", "표번")) +def create_structure(column: str) -> dict[str, Any]: + """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 8자리 · 이미 있으면 409. + 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 8자리).""" + if column not in _column_ids(): + raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") + if not _ensure("structure", column): + raise StoreError(409, f"이미 있는 구조물 도면 「{column}」") + for kind in SHEETS: + _ensure(kind, column) + return read("structure", column) -def _copy(kind: str, column: str, source: str, keep: tuple[str, ...]) -> dict[str, Any]: +def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: + """다른 열(`source`) 내용을 받는 열(`column`)로 가져옴 — 번호 · 열은 받는 쪽 그대로(두 열은 + 따로 움직임) · 통합문서는 알약 `연결` 도 같이 오고 계산값은 서버가 다시 풂.""" + if kind not in _KEY: + raise StoreError(404, f"없는 종류 「{kind}」") target = read(kind, column)["문서"] - doc = {**read(kind, source)["문서"], **{key: target.get(key) for key in keep}} - if kind == "basis": + doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}} + if kind in SHEETS: 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)) -# ── 산출근거 ────────────────────────────────────────── - - -def _col_letters(index: int) -> str: - letters = "" - index += 1 - while index: - index, rest = divmod(index - 1, 26) - letters = chr(65 + rest) + letters - return letters - - -def basis_doc(column: str, old: Any = None) -> dict[str, Any]: - """산출근거 통합문서 — 새로는 진짜 빈 시트(머리 줄 없음). - `old` = 옛 구조물 문서 `산출근거` 표(열 · 줄) — 머리 글 · 칸 값 · 열 폭을 옮김 - (옛 열 식은 없어 안 옮김).""" - columns = old.get("열") if isinstance(old, dict) and isinstance(old.get("열"), list) else None - if columns: - heads = [ - " ".join(str(h) for h in (c.get("머리") or []) if h) or c.get("id", "") for c in columns - ] - else: - heads, columns = [], [] - cells: dict[str, Any] = { - f"{_col_letters(i)}1": {"값": head, "서식": 1} for i, head in enumerate(heads) if head - } - for r, row in enumerate(old.get("줄", []) if columns else [], start=2): - values = row.get("값", {}) if isinstance(row, dict) else {} - for i, col in enumerate(columns): - value = values.get(col.get("id")) - if value not in (None, ""): - cells[f"{_col_letters(i)}{r}"] = {"값": value} - widths = ((old or {}).get("보기") or {}).get("열너비") or {} if columns else {} - # 옛 폭은 px · 새 폭은 엑셀 글자 단위(숫자 폭 7px 어림) - info = { - _col_letters(i): {"폭": round(widths[c["id"]] / 7, 1)} - for i, c in enumerate(columns) - if isinstance(widths.get(c.get("id")), (int, float)) - } - sheet: dict[str, Any] = {"id": "s1", "이름": "산출근거", "칸": cells} - if info: - sheet["열"] = info - return { - "종류": "통합문서", - "판": 1, - "열": column, - "서식": [{}, _BASIS_HEAD_STYLE] if heads else [{}], - "시트": [sheet], - "활성": "s1", - } - - -def _basis_docs() -> list[tuple[str, Any]]: - folder = FOLDER / "basis" - return [(p.stem, json.loads(p.read_bytes())) for p in sorted(folder.glob("[!.]*.json"))] - - -def ensure_basis(column: str, old: Any = None) -> bool: - """산출근거 파일이 없으면 만듦(식 없는 새 문서라 `계산값` 없음 · 첫 [저장] 때 서버가 적음). - 표번 = SC + 구조물 도면과 같은 6자리 · 만든 뒤로는 안 바뀜.""" - path = _path("basis", column) - with _LOCK: - if path.is_file(): - return False - doc = {**basis_doc(column, old), "표번": f"SC{_new_code(column)}"} - atomic_write_json(path, doc) - return True - - -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 kind in _KEY: for path in sorted((FOLDER / kind).glob("[!.]*.json")): if path.stem not in keep: path.unlink() @@ -357,10 +300,35 @@ def prune_removed_structures() -> list[str]: return gone +def _move_basis() -> list[str]: + """12장 산출근거 `basis/` → 면적산출 `area/` (내용 · 알약 연결 그대로 · 표번 칸 → 면적번 · + 시트 이름 산출근거 → 면적산출) · 옮긴 뒤 빈 `basis/` 폴더를 지움.""" + old = FOLDER / "basis" + if not old.is_dir(): + return [] + moved: list[str] = [] + with _LOCK: + for path in sorted(old.glob("[!.]*.json")): + doc = json.loads(path.read_bytes()) + doc["면적번"] = doc.pop("표번", None) # 번호는 renumber 가 SA 8자리로 + for sheet in doc.get("시트") or []: + if isinstance(sheet, dict) and sheet.get("이름") == "산출근거": + sheet["이름"] = "면적산출" + target = _path("area", path.stem) + if not target.is_file(): + atomic_write_json(target, doc) + path.unlink() + moved.append(path.stem) + if not any(old.iterdir()): + old.rmdir() + return moved + + def renumber() -> dict[str, list[str]]: - """한 번 — 옛 번호(구-NN · 표-NN · 없음)를 새 번호로(열 차례 = 종류 · 분류1 · 분류2 차례) · - 옛 빈 산출근거 기본 머리 줄 지움 · 구조물 아닌 열 파일 지움 · 빠진 파일 만듦. - 내용은 그대로 · 새 번호가 이미 있으면 그대로(두 번 돌려도 같음).""" + """한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로 + (6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · + 구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음.""" + moved = _move_basis() gone = prune_removed_structures() changed: list[str] = [] made: list[str] = [] @@ -371,23 +339,20 @@ def renumber() -> dict[str, list[str]]: 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())} + docs = {kind: dict(_docs(kind)) for kind in _KEY} for col in cols: cid = col["id"] - for kind, key, head in (("structure", "도번", "SD"), ("basis", "표번", "SC")): + for kind, key, head, _ in PARTS: 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}) + atomic_write_json(_path(kind, cid), {**_blank(kind, cid), key: number}) made.append(f"{kind}/{cid}") - continue - stripped = kind == "basis" and _strip_default_head(doc) - if doc.get(key) != number or stripped: + elif doc.get(key) != number: doc[key] = number atomic_write_json(_path(kind, cid), doc) changed.append(f"{kind}/{cid}") - return {"지움": gone, "바꿈": changed, "새로": made} + return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made} def _server_values(doc: Any) -> dict[str, Any]: @@ -397,36 +362,10 @@ def _server_values(doc: Any) -> dict[str, Any]: raise StoreError(503, str(e)) from e -def migrate_basis() -> dict[str, list[str]]: - """한 번 — 옛 구조물 문서 안 `산출근거` 표를 `basis/<열 id>.json` 으로 옮기고 - 구조물 문서에서 뺌 · 그 뒤 산출근거가 없는 집계표 열은 빈 통합문서. - 두 번 돌려도 같음(있는 파일은 안 덮음).""" - moved: list[str] = [] - for name, doc in _structures(): - if not isinstance(doc, dict) or "산출근거" not in doc: - continue - ensure_basis(name, doc["산출근거"]) - rest = {key: value for key, value in doc.items() if key != "산출근거"} - with _LOCK: - atomic_write_json(_path("structure", name), rest) - moved.append(name) - made = [column for column in _column_ids() if ensure_basis(column)] - return {"옮김": moved, "새로": made} - - def backfill_missing_structures() -> list[str]: - """구조물 열마다 빈 구조물 도면 · 산출근거가 있게 — 없는 열만 열 차례로 새로 - 만듦(그 무리 다음 번호). - 그 사이 남이 만들었거나(409) 열 규칙에 안 맞으면(404) 그 열만 건너뜀.""" - have = {name for name, _ in _structures()} + """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호). + 그 사이 남이 만들었으면 그대로 둠.""" made: list[str] = [] for cid in _column_ids(): - if cid in have: - ensure_basis(cid) - continue - try: - create_structure(cid) - made.append(cid) - except StoreError: - pass + made += [f"{kind}/{cid}" for kind in _KEY if _ensure(kind, cid)] return made diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index e3f3060b..a15b4ca1 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -1,10 +1,10 @@ """M02 양식 층 — 네 층의 자리 · 읽기 · 쓰기 · 복사 · manifest. 층 넷 (PLAN 10-5): - system `resources/master_template/{table,drawing,structure,basis}/<이름>.json` (git) - company `storage/{회사}/templates/{table,drawing,structure,basis}/` - personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,basis}/` - project `storage/{회사}/{사용자}/{프로젝트}/templates/{table,drawing,structure,basis}/` + system `resources/master_template/{table,drawing,structure,area,material}/<이름>.json` (git) + company `storage/{회사}/templates/{table,drawing,structure,area,material}/` + personal `storage/{회사}/{사용자}/templates/{table,drawing,structure,area,material}/` + project `storage/{회사}/{사용자}/{프로젝트}/templates/{…같은 다섯}/` + 초기 사본 `templates/_initial/` - 층 폴더마다 `manifest.json` — 그 폴더에 든 양식의 출처 `{"table/이름": {층, 이름, 판, 적용일}}`. @@ -26,8 +26,8 @@ from common_util.common_util_json import atomic_write_json from config import config_system LAYERS = ("system", "company", "personal", "project") -# basis = 산출근거 통합문서(구조물집계표 열마다 · 스프레드시트 `A00_Common/spreadsheet/`) -KINDS = ("table", "drawing", "structure", "basis") +# area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`) +KINDS = ("table", "drawing", "structure", "area", "material") TEMPLATES_DIRNAME = "templates" INITIAL_DIRNAME = "_initial" MANIFEST_NAME = "manifest.json" @@ -167,7 +167,8 @@ _SKELETON = { "table": ("열",), "drawing": ("entities",), "structure": ("도면.entities",), - "basis": ("시트",), + "area": ("시트",), + "material": ("시트",), } diff --git a/resources/tester/spreadsheet/test_spreadsheet_store.py b/resources/tester/spreadsheet/test_spreadsheet_store.py index c2f1f83d..698ac784 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_store.py +++ b/resources/tester/spreadsheet/test_spreadsheet_store.py @@ -1,4 +1,5 @@ -"""F — 산출근거 저장(basis 종류) · 옛 산출근거 옮김 · [저장] 때 서버 계산값. +"""F — 면적산출(area) · 재료산출(material) 저장 · 옛 산출근거(basis) 옮김 · [저장] 때 서버 계산값 · +알약 연결(`연결`)은 저장 · 복사 때 문서와 같이. 서버 Node 풀이는 `test_spreadsheet_node.py` 가 따로 봄 — 여기는 `with_server_values` 를 바꿔 끼워 「브라우저 값은 버리고 서버 값만 적는다」 · 「못 풀면 저장을 막는다」 만 잼. @@ -18,6 +19,7 @@ from M02_MasterTemplete import M02_Template_Layers as layers REAL = store.FOLDER SERVER = {"s1": {"A1": {"수": "42"}}} +LINKS = {"p1": {"시트": "s1", "칸": "B2"}} # 알약 연결 — 서버는 속을 안 봄(sub7 계약) @pytest.fixture() @@ -37,138 +39,108 @@ def client(tmp_path, monkeypatch): return TestClient(app) -def _old_structure(folder, column, basis): - doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-01"} - doc.update({"도면": {"format": 6, "entities": []}, "산출근거": basis}) - (folder / "structure" / f"{column}.json").write_text( - json.dumps(doc, ensure_ascii=False), encoding="utf-8" - ) +def _write(folder, kind, column, doc): + (folder / kind).mkdir(exist_ok=True) + (folder / kind / f"{column}.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8") -def test_옛_산출근거를_따로_파일로_옮김(client, tmp_path): - old = { - "열": [ - {"id": "work", "머리": ["공종"]}, - {"id": "detail", "머리": ["산출", "내역"]}, - {"id": "qty", "머리": ["수량(m당)"]}, - ], - "줄": [{"id": "1", "값": {"work": "터파기", "qty": 1.5}}, {"id": "2", "값": {}}], - "보기": {"열너비": {"detail": 210}}, +def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tmp_path): + """12장 파일(SD · SC 6자리 · basis/) → SA 로 옮김 · 끝에 00 · 재료산출 새로 · 내용 그대로.""" + structure = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": "rv", "도번": "SD010101"} + _write(tmp_path, "structure", "rv", {**structure, "도면": {"entities": [{"type": "line"}]}}) + old = {**store._blank("area", "rv"), "표번": "SC010101", "연결": LINKS} + old["시트"][0].update({"이름": "산출근거", "칸": {"A2": {"값": "터파기"}}}) + _write(tmp_path, "basis", "rv", old) + done = store.renumber() + assert done["옮김"] == ["rv"] and not (tmp_path / "basis").exists() + area = store.read("area", "rv")["문서"] + assert (area["면적번"], "표번" in area, area["연결"]) == ("SA01010100", False, LINKS) + assert area["시트"][0]["이름"] == "면적산출" and area["시트"][0]["칸"] == { + "A2": {"값": "터파기"} } - _old_structure(tmp_path, "pp_len", old) - done = store.migrate_basis() - assert done["옮김"] == ["pp_len"] - columns = store._column_ids() - assert set(done["새로"]) == set(columns) - {"pp_len"} - structure = json.loads((tmp_path / "structure/pp_len.json").read_text(encoding="utf-8")) - assert "산출근거" not in structure and structure["도번"] == "구-01" - basis = store.read("basis", "pp_len")["문서"] - sheet = basis["시트"][0] - assert (basis["종류"], basis["열"], sheet["id"]) == ("통합문서", "pp_len", "s1") - assert sheet["칸"] == { - "A1": {"값": "공종", "서식": 1}, - "B1": {"값": "산출 내역", "서식": 1}, - "C1": {"값": "수량(m당)", "서식": 1}, - "A2": {"값": "터파기"}, - "C2": {"값": 1.5}, - } - assert sheet["열"] == {"B": {"폭": 30.0}} - assert "계산값" not in basis # 식 없는 새 문서 — 첫 [저장] 때 서버가 적음 - # 두 번 돌려도 같음 - before = {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()} - assert store.migrate_basis() == {"옮김": [], "새로": []} - assert before == {p.name: p.read_bytes() for p in (tmp_path / "basis").iterdir()} + assert store.read("structure", "rv")["문서"]["도번"] == "SD01010100" + material = store.read("material", "rv")["문서"] + assert (material["재료번"], material["시트"][0]["이름"]) == ("SM01010100", "재료산출") + assert material["시트"][0]["칸"] == {} and material["서식"] == [{}] # 진짜 빈 시트 + before = {p: p.read_bytes() for p in tmp_path.rglob("*.json")} + assert store.renumber() == {"옮김": [], "지움": [], "바꿈": [], "새로": []} + assert before == {p: p.read_bytes() for p in tmp_path.rglob("*.json")} # 두 번 돌려도 같음 -def test_새_구조물_도면은_산출근거_따로_진짜_빈_시트(client): - made = client.post("/api/m02/structures/rv").json()["문서"] - assert "산출근거" not in made - basis = client.get("/api/m02/templates/basis/rv").json()["문서"] - assert basis["시트"][0]["칸"] == {} and basis["서식"] == [{}] # 머리 줄 없음 - - -def test_표번은_도번과_같은_6자리(client): +def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client): made1 = client.post("/api/m02/structures/rv").json()["문서"] made2 = client.post("/api/m02/structures/pv_b").json()["문서"] - 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["표번"] == "SC010101" and basis2["표번"] == "SC020101" + assert made1["도번"] == "SD01010100" and made2["도번"] == "SD02010100" + 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["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["pv_b"] == {"도번": "SD020101", "표번": "SC020101"} + assert numbers["pv_b"] == {"도번": "SD02010100", "면적번": "SA02010100", "재료번": "SM02010100"} -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" - ) - - -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": {"값": "터파기"}}) # 아래 줄에 내용 +@pytest.mark.parametrize( + ("kind", "key", "head"), [("area", "면적번", "SA"), ("material", "재료번", "SM")] +) +def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind, key, head): 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 = client.get(f"/api/m02/templates/{kind}/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"}) + got["문서"]["연결"] = LINKS + client.put(f"/api/m02/templates/{kind}/rv", json=got) + r = client.post(f"/api/m02/{kind}/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") # 번호 · 열은 받는 쪽 그대로 + ms = client.get(f"/api/m02/templates/{kind}/ms").json()["문서"] + assert (ms["시트"][0]["칸"], ms["연결"]) == ({"A1": {"식": "1+1"}}, LINKS) + assert (ms[key], ms["열"]) == (f"{head}10010100", "ms") # 번호 · 열은 받는 쪽 그대로 assert ms["계산값"] == SERVER # 서버가 다시 풂 - assert client.post("/api/m02/basis/ms/copy", params={"from": "없는열"}).status_code == 404 + assert client.post(f"/api/m02/{kind}/ms/copy", params={"from": "없는열"}).status_code == 404 -def test_저장은_서버_계산값만_적음(client): - store.ensure_basis("rv") - got = client.get("/api/m02/templates/basis/rv").json() - doc = {**got["문서"], "계산값": {"s1": {"A1": {"수": "브라우저"}}}} - saved = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc}) +def test_저장은_서버_계산값만_적음_연결_두고_번호는_서버_것(client): + store._ensure("area", "rv") + got = client.get("/api/m02/templates/area/rv").json() + number = got["문서"]["면적번"] + doc = { + **got["문서"], + "계산값": {"s1": {"A1": {"수": "브라우저"}}}, + "연결": LINKS, + "면적번": "x", + } + saved = client.put("/api/m02/templates/area/rv", json={"판": got["판"], "문서": doc}) assert saved.status_code == 200 - assert client.get("/api/m02/templates/basis/rv").json()["문서"]["계산값"] == SERVER + now = client.get("/api/m02/templates/area/rv").json()["문서"] + assert (now["계산값"], now["연결"], now["면적번"]) == (SERVER, LINKS, number) # 뼈대 없는 문서 · 판이 다르면 막음 - bad = client.put("/api/m02/templates/basis/rv", json={"판": saved.json()["판"], "문서": {}}) + bad = client.put("/api/m02/templates/area/rv", json={"판": saved.json()["판"], "문서": {}}) assert bad.status_code == 400 - stale = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": doc}) + stale = client.put("/api/m02/templates/area/rv", json={"판": got["판"], "문서": doc}) assert stale.status_code == 409 def test_못_풀면_저장을_막음(client, monkeypatch): - store.ensure_basis("rv") - got = client.get("/api/m02/templates/basis/rv").json() + store._ensure("material", "rv") + got = client.get("/api/m02/templates/material/rv").json() def broken(_book): raise spreadsheet.RecalcError("스프레드시트 계산 실패 — 시험") monkeypatch.setattr(spreadsheet, "with_server_values", broken) - r = client.put("/api/m02/templates/basis/rv", json={"판": got["판"], "문서": got["문서"]}) + r = client.put("/api/m02/templates/material/rv", json={"판": got["판"], "문서": got["문서"]}) assert r.status_code == 503 - assert client.get("/api/m02/templates/basis/rv").json()["판"] == got["판"] + assert client.get("/api/m02/templates/material/rv").json()["판"] == got["판"] def test_층_규칙과_프로젝트_복사(client, tmp_path, monkeypatch): - store.ensure_basis("rv") - doc = store.read("basis", "rv")["문서"] - layers.check_skeleton("basis", doc) - for bad in ({}, {**doc, "시트": {}}): - with pytest.raises(ValueError): - layers.check_skeleton("basis", bad) + client.post("/api/m02/structures/rv") + for kind in ("area", "material"): + doc = store.read(kind, "rv")["문서"] + layers.check_skeleton(kind, doc) + for bad in ({}, {**doc, "시트": {}}): + with pytest.raises(ValueError): + layers.check_skeleton(kind, bad) layers.check_skeleton("structure", {"도면": {"entities": []}}) # 산출근거 없어도 됨 monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path) copied = layers.seed_project(tmp_path / "project") - assert "basis/rv" in copied["작업본"] and "basis/rv" in copied["초기"] + for kind in ("area", "material"): + assert f"{kind}/rv" in copied["작업본"] and f"{kind}/rv" in copied["초기"] diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 0f033c58..6770aab5 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -1,6 +1,7 @@ -"""M02 구조물 도면(structure) — 새로 · 도번 SD+6자리 · 409 · 저장 · 지운 뒤 번호 · -제목행 번호 매김 · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사. -산출근거는 따로 파일(basis) — `resources/tester/spreadsheet/test_spreadsheet_store.py`.""" +"""M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 · +제목행 번호 매김(분류3 까지) · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사. +면적산출(area) · 재료산출(material)은 따로 파일 — +`resources/tester/spreadsheet/test_spreadsheet_store.py`.""" import json import shutil @@ -27,8 +28,9 @@ def client(tmp_path, monkeypatch): return TestClient(app) -def _num(도번: str, 표번: str) -> dict: - return {"도번": 도번, "표번": 표번} +def _num(code: str) -> dict: + """세 번호 — 같은 8자리.""" + return {"도번": f"SD{code}", "면적번": f"SA{code}", "재료번": f"SM{code}"} def test_새로_도번_자동_409_없는_열(client): @@ -39,23 +41,24 @@ def test_새로_도번_자동_409_없는_열(client): "구조물도면", "structure", "pp_len", - "SD010101", + "SD01010100", ) # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶) assert doc["도면"]["format"] == 6 assert doc["도면"]["entities"] == [] assert len(doc["도면"]["layers"]) == 1 - # 산출근거는 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서 + # 면적산출 · 재료산출은 구조물 문서 밖 — 같은 열 id 로 따로 한 통합문서씩 assert "산출근거" not in doc - assert client.get("/api/m02/templates/basis/pp_len").json()["문서"]["종류"] == "통합문서" + for kind in ("area", "material"): + assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서" - assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD020101" + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100" 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("SD010101", "SC010101"), - "rv": _num("SD020101", "SC020101"), + "pp_len": _num("01010100"), + "rv": _num("02010100"), } rows = client.get("/api/m02/templates").json() assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == { @@ -76,6 +79,12 @@ def test_저장_뼈대_거름(client): ) assert r.status_code == 400 assert client.get("/api/m02/templates/structure/pp_len").json()["문서"] == doc + now = client.get("/api/m02/templates/structure/pp_len").json()["판"] + client.put( + "/api/m02/templates/structure/pp_len", json={"판": now, "문서": {**doc, "도번": "SD9"}} + ) + got = client.get("/api/m02/templates/structure/pp_len").json()["문서"] + assert got["도번"] == "SD01010100" # 번호는 서버 것 — 화면 값을 안 받음 def test_지워도_번호를_당기지_않음_짝은_같은_번호(client): @@ -83,15 +92,16 @@ 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("", "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" + 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" def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): - """옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 차례) · 내용 그대로.""" + """옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · + 내용 그대로.""" for column in ("no", "rv", "pv_l"): doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"} doc["도면"] = {"format": 6, "entities": [{"type": "line"}], "layers": []} @@ -103,18 +113,20 @@ def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): 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 다음 번호 + "rv": "01010100", # 돌기슭막이 · (형태마다) · H=(높이마다) · 분류3 없음 = 00 + "c1": "01010200", # 같은 종류 · 분류1 · 다음 분류2 + "pv_b": "02010100", # 콘크리트포장 · T=(두께마다) · B + "pv_l": "02010200", + "fb": "06010000", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00 + "be": "14000000", # 소단 — 분류 모두 없음 + "h_rip_c": "27010000", # 돌붙임 · 찰붙임 + "h_rip_m": "27020000", # 돌붙임 · 메붙임 — 분류1 다음 번호 } for column, code in expect.items(): - assert numbers[column] == _num(f"SD{code}", f"SC{code}"), column + assert numbers[column] == _num(code), column assert store.read("structure", "rv")["문서"]["도면"]["entities"] == [{"type": "line"}] - assert store.renumber() == {"지움": [], "바꿈": [], "새로": []} # 두 번 돌려도 같음 + again = {"옮김": [], "지움": [], "바꿈": [], "새로": []} + assert store.renumber() == again # 두 번 돌려도 같음 def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(client): @@ -125,17 +137,19 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c new = {"id": "pv_x", "머리": ["콘크리트포장", "T=(두께마다)", "X"], "단위": "m", "꼴": "수"} kind = {"id": "zz", "머리": ["새 구조물", None, None], "단위": "개소", "꼴": "수"} text = {"id": "note2", "머리": ["메모", None, None], "단위": None, "꼴": "글"} - table["열"] += [new, kind, text] + third = {"id": "pv_y", "머리": ["콘크리트포장", "T=(두께마다)", "B", "H=1"], "꼴": "수"} + table["열"] += [new, kind, text, third] 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"): + for kind_ in ("structure", "area", "material"): 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") # 새 종류 = 종류 다음 번호 + assert numbers["pv_x"] == _num("02010600") # 지운 04 는 비워 둠 · 무리 다음 + assert numbers["zz"] == _num("37000000") # 새 종류 = 종류 다음 번호 + assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호 def test_다른_열_도면을_복사해_가져옴(client): @@ -147,8 +161,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["열"]) == ("SD100101", "ms") # 번호 · 열은 받는 쪽 그대로 - assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD010101" + assert (ms["도번"], ms["열"]) == ("SD10010100", "ms") # 번호 · 열은 받는 쪽 그대로 + assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD01010100" bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"}) assert bad.status_code == 404