feat(M02): 집계 항목 폴더 구조 서버 — 경로 · 일련번호 · 나무 고침 길(PLAN 26-2a)
- 항목 경로 = 폴더 이름 배열(깊이 자유) · 집계표 열 머리(빈 칸 건너뜀)가 곧 경로 · 열에 경로를 같이 적음 · 머리 줄 수(층)는 가장 깊은 경로에 맞춤(넷 밑으로는 안 줄임) · 빈 폴더는 집계표 폴더 칸(M02_MasterTemplete_Tree)
- 표 저장과 나무 고침 한 줄기 — 나무 고침은 집계표 문서를 고쳐 같은 집계표 저장(store.write)을 탐(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기가 함께)
- 나무 고침 길 POST /api/m02/layers/{layer}/tree(add-folder · add-item · rename · move · delete · 판 409 · 422 · 404 · 층 권한) · 읽기 GET 같은 길 {판, 폴더, items}
- 번호 = 새 항목은 자리 뜻 없는 일련번호(있던 번호 · 지운 번호 가장 큰 수 + 1) · 있던 번호 그대로
- SS 장부 = M02_MasterTemplete_Book 로 떼어 냄(Store 543 → 478) · 줄에 경로 · 이름 · 옛 종류 · 분류1~3 = 경로 앞 넷 · 경로 없는 옛 줄은 읽을 때 채움 · 옮기는 함수 migrate_paths
- 목록 길 /api/m02/items 줄에 경로 · 이름(경로 이음) · 자재 조합 · 갈라 내기 이름도 경로 이음
- 마스터 집계표 · 장부를 경로 모양으로 옮김(51 항목 · 키 · 번호 그대로 · 빈 글 머리는 null)
- 시험 test_m02_tree 5(옛 51 항목 · 옮기기 두 번 같음 · 깊이 1 · 2 · 5 · 이름 · 옮기기 · 지우기 · 마지막 항목 · 409 · 403 · 표 저장 한 줄기) · 번호 시험을 일련번호 규칙으로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user