feat(M02): 항목 키 체계 · 품번 새 모양(PLAN 26-4a)
- 항목 키 = 마스터 SS + 6자리 · 회사 SC{회사}- + 6자리 · 개인 SP{사용자}- + 6자리(장부 key_head · 층 폴더로 가름) · 한 번 주면 안 바뀜 · 지운 키 다시 안 씀 · 층 사이 복사 · 개인에 저장 · 새 항목은 그 층 새 키 + 본뜬 곳
- 회사 · 개인 층의 옛 SS 키는 장부를 맞출 때(처음 읽을 때 한 번) 새 모양으로 옮김(숫자 여섯 그대로 · 겹치면 다음 번호) · 옛 키는 줄 옛키 에 남김 · 옛 키로도 찾음(store.find_combo · 나무 고침 · 입력 목록 · 항목 복사 · M02_Item_Source.row_of)
- summary_item 키 검사 — SC · SP 는 그 층 · 머리 번호 = owner 일 때만 · 옛 SS 는 어느 층이든 받음
- 품번(도번 SD · 면적번 SA · 재료번 SM) 새 일련 = 붙임표 + 6자리(SD-000001) — 옛 8자리(제목행 자리) · 12장 옛 6자리(SD010101)와 모양이 달라 안 겹침 · 옛 번호는 그대로 유효 · 새 모양끼리 셈 · 지운 번호 안 씀
- 시험 test_m02_item_keys 7(층마다 키 모양 · 안 바뀜 · 다시 안 씀 · 복사 본뜬 곳 · 옛 SS 옮김 · 옛 키로 찾음 · 겹친 숫자 · 참조 키 모양 · 120 형제 깊이 6 번호 · 층마다 번호 따로 · 키대장 안 겹침) · 번호 · 키 시험 기대값을 새 모양으로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -49,7 +49,8 @@ def read_json(path: Path) -> Any:
|
||||
def row_of(document: Any, key: str) -> dict[str, Any] | None:
|
||||
rows = document.get("줄") if isinstance(document, dict) else None
|
||||
for row in rows or []:
|
||||
if isinstance(row, dict) and row.get("키") == key:
|
||||
# 옛 키로도 — 회사 · 개인 층 SS 키는 새 모양(SC · SP)으로 옮기며 `옛키` 에 남김(PLAN 26-4a)
|
||||
if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,30 +1,72 @@
|
||||
"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a).
|
||||
"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a · 26-4a).
|
||||
|
||||
제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 ·
|
||||
키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억).
|
||||
키(층마다 머리가 다름 · 폴더 · 종류 뜻 없는 일련번호 6자리) —
|
||||
마스터 `SS000001` · 회사 `SC{회사번호}-000001` · 개인 `SP{사용자번호}-000001`
|
||||
한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) · 층 사이 복사 ·
|
||||
개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은
|
||||
옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에
|
||||
남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`).
|
||||
줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합,
|
||||
도번, 면적번, 재료번, 바인딩, 펼침}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 — 저장소는
|
||||
`store.sync_combos` · `store.read_combos` 이름으로 이 파일을 부름.
|
||||
도번, 면적번, 재료번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 —
|
||||
저장소는 `store.sync_combos` · `store.read_combos` · `store.find_combo` 이름으로 이 파일을 부름.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
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
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
HEAD = "SS"
|
||||
HEAD = "SS" # 마스터(과 회사 · 개인 · 프로젝트 층 밖 모든 층)
|
||||
_OLD = re.compile(r"^SS\d{6}$")
|
||||
NOTE = (
|
||||
"구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · "
|
||||
"키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열"
|
||||
"키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 "
|
||||
"지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열"
|
||||
)
|
||||
|
||||
|
||||
def _row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]:
|
||||
def key_head(root: Path) -> str:
|
||||
"""층 폴더 → 키 머리 — 회사 `SC{회사}-` · 개인 `SP{사용자}-` ·
|
||||
그 밖(마스터 · 프로젝트 사본) `SS`."""
|
||||
try:
|
||||
parts = Path(root).resolve().relative_to(layers.storage_root()).parts
|
||||
except ValueError:
|
||||
return HEAD
|
||||
if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME:
|
||||
return f"SC{parts[0]}-"
|
||||
if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME:
|
||||
return f"SP{parts[1]}-"
|
||||
return HEAD
|
||||
|
||||
|
||||
def _number(head: str, key: str) -> int | None:
|
||||
"""그 머리의 키면 여섯 자리 수 · 아니면 None."""
|
||||
found = re.fullmatch(re.escape(head) + r"(\d{6})", str(key))
|
||||
return int(found.group(1)) if found else None
|
||||
|
||||
|
||||
def find(rows: list[dict[str, Any]], key: str) -> dict[str, Any] | None:
|
||||
"""키로 줄 — 지금 키 또는 옮기기 전 옛 키(프로젝트 복사본 · 구조물 기록이 들고 있는 것)."""
|
||||
key = str(key)
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _row(
|
||||
key: str, col: dict[str, Any], numbers: dict[str, str], olds: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
path = tree.path_of(col)
|
||||
extra = {"옛키": olds} if olds else {}
|
||||
return {
|
||||
"키": key,
|
||||
"열": col["id"],
|
||||
@@ -37,28 +79,40 @@ def _row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, An
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in store.PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def sync() -> dict[str, Any]:
|
||||
"""장부를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
|
||||
"""장부를 집계표에 맞춤 — 새 열 = 그 층 새 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
옛 모양 키(회사 · 개인 층의 SS)는 새 모양으로(옛 키는 `옛키`) · 줄 차례 = 집계표 열 차례.
|
||||
바뀐 것이 없으면 파일을 안 씀."""
|
||||
path = store.root() / store.COMBO_FILE
|
||||
head = key_head(store.root())
|
||||
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()]
|
||||
before = {row["열"]: row for row in old.get("줄", []) if isinstance(row, dict)}
|
||||
taken = {n for row in before.values() if (n := _number(head, row.get("키"))) is not None}
|
||||
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}"
|
||||
was = before.get(col["id"]) or {}
|
||||
key, olds = was.get("키"), list(was.get("옛키") or [])
|
||||
if key is not None and _number(head, key) is None: # 옛 모양 — 숫자 여섯 그대로 옮김
|
||||
olds.append(key)
|
||||
digits = int(key[2:]) if _OLD.match(str(key)) else None
|
||||
key = f"{head}{digits:06d}" if digits and digits not in taken else None
|
||||
if key:
|
||||
taken.add(digits)
|
||||
if key is None:
|
||||
key = f"{head}{after:06d}"
|
||||
taken.add(after)
|
||||
after += 1
|
||||
rows.append(_row(keys[col["id"]], col, numbers.get(col["id"], {})))
|
||||
rows.append(_row(key, col, numbers.get(col["id"], {}), olds))
|
||||
doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after}
|
||||
if old.get("지운번호"):
|
||||
doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌
|
||||
doc["지운번호"] = old["지운번호"] # 지운 열의 번호 — 다시 안 줌
|
||||
doc["줄"] = rows
|
||||
if doc != old:
|
||||
atomic_write_json(path, doc)
|
||||
@@ -66,10 +120,13 @@ def sync() -> dict[str, Any]:
|
||||
|
||||
|
||||
def read() -> dict[str, Any]:
|
||||
"""장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · `경로` 가 없던 옛 줄은 읽을 때
|
||||
종류 · 분류1~3 에서 채움(빈 분류 건너뜀 · 파일은 다음 [저장] 때 새 모양)."""
|
||||
"""장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · 회사 · 개인 층의 옛 SS 키는 새
|
||||
모양으로 옮김 · `경로` 가 없던 옛 줄은 읽을 때 종류 · 분류1~3 에서 채움(빈 분류 건너뜀)."""
|
||||
path = store.root() / store.COMBO_FILE
|
||||
doc = json.loads(path.read_bytes()) if path.is_file() else sync()
|
||||
head = key_head(store.root())
|
||||
if head != HEAD and any(_number(head, r.get("키")) is None for r in doc.get("줄") or []):
|
||||
doc = sync() # 회사 · 개인 층의 옛 SS 키 — 처음 읽을 때 한 번 새 모양으로(옛키 남김)
|
||||
for row in doc.get("줄") or []:
|
||||
if "경로" not in row:
|
||||
row["경로"] = tree.row_path(row)
|
||||
|
||||
@@ -45,7 +45,7 @@ def pill_key(logic: str, name: str) -> str:
|
||||
|
||||
def _item(key: str) -> dict[str, Any]:
|
||||
"""지금 층(`store.at`)의 항목 줄 — 없으면 404."""
|
||||
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
|
||||
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
return row
|
||||
|
||||
@@ -112,7 +112,7 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d
|
||||
새 번호 · 새 SS 키 ·
|
||||
열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`."""
|
||||
with store.at(source):
|
||||
row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None)
|
||||
row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도
|
||||
if row is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
col_id = row["열"]
|
||||
|
||||
@@ -230,7 +230,10 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]:
|
||||
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("줄", [])}
|
||||
ids = {} # 키 · 옛 키(회사 · 개인 층 SS) → 열 id
|
||||
for r in store.read_combos().get("줄", []):
|
||||
for key in [r.get("키"), *(r.get("옛키") or [])]:
|
||||
ids[str(key)] = str(r.get("열"))
|
||||
try:
|
||||
doc, note = tree.apply(got["문서"], body, ids)
|
||||
except tree.TreeError as e:
|
||||
|
||||
@@ -47,8 +47,12 @@ def at(folder: str | Path) -> Iterator[Path]:
|
||||
# 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) —
|
||||
# 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/`
|
||||
# (둘 다 스프레드시트 통합문서)
|
||||
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 — 한 열의 세 번호는 같은 8자리 · 한 번 준 번호는
|
||||
# 파일에 남아 안 바뀜(PLAN 13장) · 새 항목은 자리 뜻 없는 일련번호(PLAN 26-1 — 있던 번호는 그대로)
|
||||
# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는
|
||||
# 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양:
|
||||
# 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌)
|
||||
# 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 ·
|
||||
# 12장 옛 6자리(`SD010101`)와 글자 모양이 달라 겹치지 않음 · 층마다 따로 셈
|
||||
# (파일은 열 id 로 가름 · 번호는 보이는 이름)
|
||||
# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`)
|
||||
STRUCTURE_TABLE = "구조물집계표"
|
||||
# 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`)
|
||||
@@ -66,7 +70,7 @@ PARTS = (
|
||||
)
|
||||
_KEY = {kind: key for kind, key, _, _ in PARTS}
|
||||
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
|
||||
_CODE = re.compile(r"^S[DAM](\d{8})$")
|
||||
_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$")
|
||||
_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
|
||||
@@ -203,7 +207,8 @@ def _docs(kind: str) -> list[tuple[str, Any]]:
|
||||
|
||||
|
||||
def _code(doc: Any, key: str) -> str | None:
|
||||
"""문서의 번호 8자리 — 12장 6자리는 끝에 00 · 옛 `구-01` · `표-01` · 없음은 None."""
|
||||
"""문서 번호의 뒷자리 — 옛 8자리 · 새 `-` + 6자리 · 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)
|
||||
@@ -212,7 +217,7 @@ def _code(doc: Any, key: str) -> str | None:
|
||||
|
||||
|
||||
def _codes() -> dict[str, str]:
|
||||
"""`{열 id: 8자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
|
||||
"""`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번."""
|
||||
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))})
|
||||
@@ -248,20 +253,24 @@ def _column_ids() -> list[str]:
|
||||
return [col["id"] for col in _structure_columns()]
|
||||
|
||||
|
||||
_SERIAL = re.compile(r"^-(\d{6})$")
|
||||
|
||||
|
||||
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}"
|
||||
"""새 항목 번호 뒷자리 — 붙임표 + 일련 6자리(새 모양 번호 · 지운 번호 가운데 가장 큰 수 + 1) ·
|
||||
옛 8자리는 셈에 안 넣음(모양이 달라 겹칠 일 없음)."""
|
||||
found = (_SERIAL.match(str(c)) for c in [*taken.values(), *(retired or [])])
|
||||
return f"-{max((int(m.group(1)) for m in found if m), default=0) + 1:06d}"
|
||||
|
||||
|
||||
def _new_code(column: str) -> str:
|
||||
"""이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
|
||||
"""이 열이 쓸 번호 뒷자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름)."""
|
||||
taken = _codes()
|
||||
return taken[column] if column in taken else _serial(taken, _retired())
|
||||
|
||||
|
||||
def _retired() -> list[str]:
|
||||
"""지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
|
||||
"""지운 열의 번호 뒷자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌)."""
|
||||
path = root() / COMBO_FILE
|
||||
return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else []
|
||||
|
||||
@@ -289,7 +298,7 @@ def _blank(kind: str, column: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _ensure(kind: str, column: str, source: str | None = None) -> bool:
|
||||
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로.
|
||||
"""그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 뒷자리)와 같이 만듦 · 있으면 그대로.
|
||||
`source` 열 파일이 있으면 빈 문서 대신 그 내용(알약 `연결` 포함 · 계산값은 서버가 다시 풂)."""
|
||||
path = _path(kind, column)
|
||||
if path.is_file():
|
||||
@@ -329,8 +338,8 @@ def _take_origins(doc: Any) -> dict[str, str]:
|
||||
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 8자리 · 이미 있으면 409.
|
||||
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 8자리)."""
|
||||
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
|
||||
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리)."""
|
||||
if column not in _column_ids():
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
|
||||
if not _ensure("structure", column):
|
||||
@@ -471,6 +480,13 @@ def sync_combos() -> dict[str, Any]:
|
||||
return book.sync()
|
||||
|
||||
|
||||
def find_combo(key: str) -> dict[str, Any] | None:
|
||||
"""키(또는 옮기기 전 옛 키)로 장부 줄 — 없으면 None(`Book.find`)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
|
||||
return book.find(book.read().get("줄", []), key)
|
||||
|
||||
|
||||
def read_combos() -> dict[str, Any]:
|
||||
"""조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`)."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Book as book
|
||||
|
||||
@@ -142,7 +142,7 @@ def _path(value: Any, what: str = "경로") -> list[str]:
|
||||
|
||||
|
||||
def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]:
|
||||
"""SS 키(또는 열 id)로 항목 열 — 없으면 404."""
|
||||
"""항목 키 · 옛 키(또는 열 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:
|
||||
|
||||
Reference in New Issue
Block a user