feat(M02): 집계 항목 복사본을 구조물마다 — templates/structures/{구조물 id} · 옛 항목 폴더 옮겨 받기 · 재적용(덮임 알림) · 개인에 저장 · 프로젝트에서 새 항목 · 지정 바꿈 · 삭제 때 고친 것은 _지난 (PLAN 26-2b)

- 같은 항목을 고른 구조물 둘도 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 따로 가짐 · 폴더 = structure_id 또는 pipe@측점
- 옛 templates/items/{층}_{주인}_{키} 는 읽을 때 그 항목을 쓰는 구조물마다 복사(고친 값 유지) · 안 쓰이고 고친 것은 structures/_지난
- 길: 미리 가져오기 {structure, layer, owner, key} · POST …/{폴더}/reapply(refresh 옛 이름) · …/save-as {to, 경로, 이름} · …/items/new {structure, 경로, 이름, 단위} · 회사 없음 409
- manifest 원본에 경로(26-1 · 경로 칸 우선 · 옛 종류 · 분류는 건너뛰며 받음) · 원본이 바뀌거나 지워져도 복사본 · 계산 그대로(목록 마스터바뀜 · 원본없음)
- 채움(M02_Table_Fill)은 구조물 복사본의 열 정의를 읽음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-30 07:45:20 +09:00
co-authored by Claude Opus 5.5
parent bf21676ef5
commit fbb214cd94
8 changed files with 683 additions and 151 deletions
+92 -22
View File
@@ -1,8 +1,11 @@
"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b).
"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산
(PLAN 25-2b · 26-2b).
복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 —
쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움.
옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음).
복사본 = `{프로젝트}/templates/structures/{구조물 id}/`
(`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id.
파일마다 판 = 내용 해시 — 쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 지울 때
`_지난/` 에 옮겨 둠. 옛 복사본(새 파일 없음) · 옛 항목 폴더는 읽을 때 채우고 옮겨 받음.
계산은 복사본만 씀(마스터 현재값 안 읽음) — 원본 항목이 바뀌거나 지워져도 그대로.
"""
from __future__ import annotations
@@ -13,17 +16,25 @@ from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import snapshot_folder_name
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Template_Layers as layers
from M02_MasterTemplete.M02_Item_Snapshot import (
FOLDER_PATTERN,
LOCAL_FILES,
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
build_snapshot,
complete_old,
folder_name,
items_dir,
migrate_items,
now_text,
retire,
same_item,
source_files,
structures_dir,
write_snapshot,
)
from M02_MasterTemplete.M02_Item_Source import (
@@ -48,7 +59,6 @@ KINDS = {
#: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출)
SKELETON = {"도면": "structure", "면적": "area", "재료": "material"}
SEATS = ("면적", "재료", "선택", None)
_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$")
_MT = re.compile(r"^MT\d{6}$")
@@ -58,11 +68,18 @@ class Stale(ValueError):
self.current = current
def _migrated(project_root: str | Path) -> Path:
"""구조물 복사본 뿌리 — 옛 항목 폴더가 남았으면 먼저 옮겨 받음."""
if items_dir(project_root).is_dir():
migrate_items(project_root)
return structures_dir(project_root)
def folder_of(project_root: str | Path, folder: str) -> Path:
"""복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
if not _FOLDER.match(folder):
"""구조물 복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404)."""
if not FOLDER_PATTERN.match(folder):
raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」")
target = items_dir(project_root) / folder
target = _migrated(project_root) / folder
if not (target / MANIFEST_NAME).is_file():
raise LookupError(f"복사본이 없음 「{folder}」")
complete_old(target)
@@ -79,10 +96,10 @@ def source_changed(manifest: dict[str, Any]) -> bool | None:
def list_items(project_root: str | Path) -> list[dict[str, Any]]:
base = items_dir(project_root)
base = _migrated(project_root)
out = []
for child in sorted(base.iterdir()) if base.is_dir() else []:
if not child.is_dir() or not _FOLDER.match(child.name):
if not child.is_dir() or not FOLDER_PATTERN.match(child.name):
continue
if not (child / MANIFEST_NAME).is_file():
continue
@@ -215,12 +232,15 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]:
return out
def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
"""층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림."""
def reapply(project_root: str | Path, folder: str) -> dict[str, Any]:
"""재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림.
원본과 달라 덮인 파일(도면 · 면적 · 재료 · 자재조합 · 입력연결)은 `덮임` 에 알림."""
target = folder_of(project_root, folder)
manifest = read_json(target / MANIFEST_NAME) or {}
values = read_json(target / VALUES_FILE)
picks = read_json(target / PICKS_FILE)
before = {name: read_json(target / file) for name, file in KINDS.items()}
files = build_snapshot(manifest["원본"])
labor = files.get(LABOR_FILE) or {}
files[VALUES_FILE] = _kept_values(values, labor)
@@ -230,24 +250,74 @@ def refresh(project_root: str | Path, folder: str) -> dict[str, Any]:
old.unlink()
for name, content in files.items():
atomic_write_json(target / name, content)
source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)}
covered = [
name
for name, file in KINDS.items()
if file not in LOCAL_FILES and before[name] != files.get(file)
]
source = version_of(source_files(files))
stamp = now_text()
fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp}
fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp}
atomic_write_json(target / MANIFEST_NAME, fresh)
return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]}
return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered}
def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]:
"""구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음)."""
folder = snapshot_folder_name(item)
target = items_dir(project_root) / folder
refresh = reapply # 25-1b 옛 이름(새로 가져오기)
def prefetch(project_root: str | Path, owner_id: str, item: dict[str, Any]) -> dict[str, Any]:
"""구조물 복사본을 미리 가져옴(아직 [저장] 전) — 같은 항목이면 그대로 · 다르면 새로.
[저장] 뒤에도 그 구조물이 이 항목을 안 쓰면 맞추기가 지움(고친 흔적이 있으면 `_지난/`)."""
folder = folder_name(owner_id)
if not FOLDER_PATTERN.match(folder):
raise ValueError(f"구조물 id 모양이 아님 「{owner_id}」")
target = _migrated(project_root) / folder
manifest = read_json(target / MANIFEST_NAME)
made = not isinstance(manifest, dict)
made = not (isinstance(manifest, dict) and same_item(manifest, item))
if made:
manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()})
if target.is_dir():
retire(target)
manifest = write_snapshot(target, item, owner_id, {"미리가져옴": now_text()})
return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")}
def save_as(
project_root: str | Path, folder: str, company: int, user: int, path: list[str], name: str
) -> dict[str, Any]:
"""[개인에 저장] — 복사본 내용으로 개인 층에 새 항목(단가 · 입력값 · 고른자재는 안 가져감)."""
target = folder_of(project_root, folder)
manifest = read_json(target / MANIFEST_NAME) or {}
made = personal.create_item(
personal.personal_folder(company, user),
path,
name,
None,
copy=target,
origin=manifest.get("원본"),
)
return {**made, "summary_item": personal.personal_item(company, user, made)}
def new_item(
project_root: str | Path,
owner_id: str,
company: int,
user: int,
path: list[str],
name: str,
unit: str | None,
) -> dict[str, Any]:
"""프로젝트에서 새 항목 — 개인 층에 빈 항목을 만들고 그 구조물 복사본으로.
돌려준 `summary_item` 을 화면이 그 구조물에 지정해 [저장] 해야 복사본이 남음(안 하면 맞추기가
다른 항목 · 지정 없음으로 보고 지움)."""
made = personal.create_item(personal.personal_folder(company, user), path, name, unit)
item = personal.personal_item(company, user, made)
got = prefetch(project_root, owner_id, item)
return {**made, "폴더": got["폴더"], "summary_item": item}
def calc(project_root: str | Path, folder: str) -> dict[str, Any]:
"""복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음."""
target = folder_of(project_root, folder)