Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_2
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3).
|
||||
|
||||
[저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤:
|
||||
- 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사
|
||||
(`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA · `재료.json` SM ·
|
||||
`자재조합.json` UM 줄 · `일위대가.json` UA 줄 + 담은 로직 줄 · `manifest.json`)
|
||||
- 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트
|
||||
값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로
|
||||
- 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제)
|
||||
원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가는 M01 시스템 것.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
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_Template_Layers as layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ITEMS_DIRNAME = "items"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
TABLE_NAME = "구조물집계표"
|
||||
UM_FILE = "자재조합.json"
|
||||
UA_FILE = "일위대가조합.json" # M01 시스템(`resources/master_data/`)
|
||||
#: 열 id → 스냅샷 파일(구조물 도면 · 면적산출 · 재료산출)
|
||||
PART_FILES = (("structure", "도면.json"), ("area", "면적.json"), ("material", "재료.json"))
|
||||
|
||||
|
||||
def items_dir(project_root: str | Path) -> Path:
|
||||
return layers.project_dir(project_root) / ITEMS_DIRNAME
|
||||
|
||||
|
||||
def layer_root(item: dict[str, Any]) -> Path:
|
||||
"""항목 원본 층 뿌리 — 시스템 · 회사 · 개인(21-2 층 폴더 함수가 오면 그리로)."""
|
||||
if item["layer"] == "system":
|
||||
return layers.system_dir()
|
||||
if item["layer"] == "company":
|
||||
return layers.company_dir(item["owner"])
|
||||
company, user = str(item["owner"]).split("/")
|
||||
return layers.personal_dir(company, user)
|
||||
|
||||
|
||||
def _read(path: Path) -> Any:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _row(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:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _labor_rows(ua_key: str) -> dict[str, Any] | None:
|
||||
"""UA 줄 + 담은 로직 줄(M01 시스템 · 없는 로직은 빼고 키만 `빠진로직`)."""
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
|
||||
ua = _row(_read(Path(m01.FOLDER) / UA_FILE), ua_key)
|
||||
if ua is None:
|
||||
return None
|
||||
wanted = [str(entry.get("로직")) for entry in ua.get("담은로직") or [] if entry.get("로직")]
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
raw: dict[str, Any] = {} # M01 적재본은 수가 Decimal — 줄은 원본 파일 JSON 그대로 담음
|
||||
for key in wanted:
|
||||
try:
|
||||
file = m01.logic(key)["file"]
|
||||
except m01.StoreError:
|
||||
continue
|
||||
if file not in raw:
|
||||
raw[file] = _read(Path(m01.FOLDER) / file)
|
||||
row = _row(raw[file], key)
|
||||
if row is not None:
|
||||
found[key] = row
|
||||
return {
|
||||
"줄": ua,
|
||||
"로직": [found[key] for key in wanted if key in found],
|
||||
"빠진로직": [key for key in wanted if key not in found],
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층에서 항목 내용을 모음 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError."""
|
||||
root = layer_root(item)
|
||||
row = _row(_read(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
raise LookupError(f"집계 항목 {item['key']} 이 {item['layer']} 층에 없음")
|
||||
column_id = str(row.get("열") or "")
|
||||
table = _read(root / "table" / f"{TABLE_NAME}.json") or {}
|
||||
column = next((c for c in table.get("열") or [] if c.get("id") == column_id), None)
|
||||
files: dict[str, Any] = {"집계.json": {"줄": row, "열": column}}
|
||||
for kind, name in PART_FILES:
|
||||
part = _read(root / kind / f"{column_id}.json") if column_id else None
|
||||
if part is not None:
|
||||
files[name] = part
|
||||
if row.get("자재조합"):
|
||||
um = _row(_read(root / UM_FILE), str(row["자재조합"]))
|
||||
if um is not None:
|
||||
files["자재조합.json"] = um
|
||||
if row.get("일위대가"):
|
||||
labor = _labor_rows(str(row["일위대가"]))
|
||||
if labor is not None:
|
||||
files["일위대가.json"] = labor
|
||||
return files
|
||||
|
||||
|
||||
def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]:
|
||||
"""`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`)."""
|
||||
found: dict[str, tuple[dict[str, Any], list[str]]] = {}
|
||||
|
||||
def add(item: dict[str, Any] | None, owner_id: str) -> None:
|
||||
if item:
|
||||
found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id)
|
||||
|
||||
_, structures = load_structures(str(project_root))
|
||||
for index, structure in enumerate(structures):
|
||||
add(structure.summary_item, structure.structure_id or f"#{index}")
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}")
|
||||
return found
|
||||
|
||||
|
||||
def _version(files: dict[str, Any]) -> str:
|
||||
raw = json.dumps(files, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()[:16]
|
||||
|
||||
|
||||
def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
|
||||
"""지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름."""
|
||||
base = items_dir(project_root)
|
||||
wanted = assignments(project_root)
|
||||
made: list[str] = []
|
||||
failed: list[str] = []
|
||||
for folder, (item, users) in sorted(wanted.items()):
|
||||
target = base / folder
|
||||
manifest = _read(target / MANIFEST_NAME)
|
||||
if isinstance(manifest, dict):
|
||||
if manifest.get("구조물") != users:
|
||||
atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users})
|
||||
continue
|
||||
try:
|
||||
files = build_snapshot(item)
|
||||
except (LookupError, ValueError) as error:
|
||||
logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error)
|
||||
failed.append(folder)
|
||||
continue
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
atomic_write_json(target / name, content)
|
||||
# manifest 는 마지막 — 있으면 다 쓴 폴더(도중에 끊기면 다음 저장이 다시 씀)
|
||||
atomic_write_json(
|
||||
target / MANIFEST_NAME,
|
||||
{
|
||||
"원본": item,
|
||||
"판": _version(files),
|
||||
"적용일": datetime.now().isoformat(timespec="seconds"),
|
||||
"구조물": users,
|
||||
},
|
||||
)
|
||||
made.append(folder)
|
||||
removed: list[str] = []
|
||||
if base.is_dir():
|
||||
for child in sorted(base.iterdir()):
|
||||
if child.is_dir() and child.name not in wanted:
|
||||
shutil.rmtree(child)
|
||||
removed.append(child.name)
|
||||
return {"만듦": made, "지움": removed, "못만듦": failed}
|
||||
|
||||
|
||||
def safe_sync(project_root: str | Path) -> None:
|
||||
"""저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤)."""
|
||||
try:
|
||||
result = sync_snapshots(project_root)
|
||||
if any(result.values()):
|
||||
logger.info("집계 항목 스냅샷: %s", result)
|
||||
except Exception:
|
||||
logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root)
|
||||
|
||||
|
||||
def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None."""
|
||||
data = _read(items_dir(project_root) / snapshot_folder_name(item) / "집계.json")
|
||||
return data if isinstance(data, dict) else None
|
||||
@@ -106,6 +106,7 @@ def collect_items(
|
||||
"start_m": item.start_m,
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
"summary_item": item.summary_item,
|
||||
}
|
||||
)
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
@@ -120,6 +121,7 @@ def collect_items(
|
||||
"end_m": None,
|
||||
"options": _with_defaults(types.get(type_id), dict(point.options or {})),
|
||||
"관연장": _nearest(lengths, chainage) if type_id == "pipe" else None,
|
||||
"summary_item": point.summary_item,
|
||||
}
|
||||
)
|
||||
return items
|
||||
@@ -186,6 +188,27 @@ def _sources(binding: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [main, *(binding.get("더함") or [])]
|
||||
|
||||
|
||||
def _home(column: dict[str, Any]) -> str:
|
||||
"""펼친 열이면 마스터 열의 묶음 — 지정 항목이 고른 열(묶음)과 견줌."""
|
||||
return _group(_frame(column).get("원열") or column)
|
||||
|
||||
|
||||
def _assigned_match(source: dict[str, Any], item: dict[str, Any], column: dict[str, Any]) -> bool:
|
||||
"""지정 항목 모드 — 구조물이 고른 열(묶음)에만 · 종류 · 조건 · 옵션으로 자동 맞추지 않음.
|
||||
|
||||
값을 읽을 출처 = 바인딩 출처 가운데 그 구조물 종류 것(없으면 주 출처).
|
||||
"""
|
||||
if item.get("열묶음") != _home(column):
|
||||
return False
|
||||
sources = _sources(_frame(column))
|
||||
typed = [entry for entry in sources if entry.get("종류") == item["type_id"]]
|
||||
return source == (typed[0] if typed else sources[0])
|
||||
|
||||
|
||||
def _auto_match(source: dict[str, Any], item: dict[str, Any], _column: dict[str, Any]) -> bool:
|
||||
return _matches(source, item)
|
||||
|
||||
|
||||
def _expansion(source: dict[str, Any], item: dict[str, Any]) -> tuple[str, ...]:
|
||||
values = []
|
||||
for key in source.get("펼침") or []:
|
||||
@@ -313,6 +336,7 @@ def _expand_columns(
|
||||
items: list[dict[str, Any]],
|
||||
variables: dict[str, Any],
|
||||
choices: dict[str, list[str]],
|
||||
match: Any = _auto_match,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, list[tuple[str, str, dict[str, Any]]]]]:
|
||||
"""펼침 열 → 값마다 열 · 묶음은 값마다 한데(관 Φ800 관매설 · 커플링 → Φ1000 …).
|
||||
|
||||
@@ -333,7 +357,7 @@ def _expand_columns(
|
||||
found = groups.setdefault(_group(column), set())
|
||||
for source in _sources(binding):
|
||||
# 값이 빈 구조물도 열은 세움 — 놓였는데 수량이 빈칸인 것이 보여야 함
|
||||
found.update(_expansion(source, item) for item in items if _matches(source, item))
|
||||
found.update(_expansion(source, item) for item in items if match(source, item, column))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
made: dict[str, list[tuple[str, str, dict[str, Any]]]] = {}
|
||||
@@ -394,14 +418,19 @@ def fill_document(
|
||||
document: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
types: dict[str, StructureType] | None = None,
|
||||
assigned: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본)."""
|
||||
"""표 문서 + 설계 구조물 목록 → 채운 표 문서(새 사본).
|
||||
|
||||
`assigned` = 지정 항목 모드(`fill_table`) — 구조물마다 고른 열(`열묶음`)에만 넣음.
|
||||
"""
|
||||
match = _assigned_match if assigned else _auto_match
|
||||
types = types or structure_type_map()
|
||||
doc = copy.deepcopy(document)
|
||||
variables = dict(doc.get("변수") or {})
|
||||
interval = _number(variables.get("측점간격_m")) or 20.0
|
||||
rule_columns = _unexpand(doc.get("열") or [])
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types))
|
||||
columns, made = _expand_columns(rule_columns, items, variables, _choices(types), match)
|
||||
notes: list[str] = []
|
||||
|
||||
# 줄 자리 — 관은 한 줄에 하나(둘째 관부터 줄을 나눔)
|
||||
@@ -427,7 +456,7 @@ def fill_document(
|
||||
if source.get("값") in (None, "식"):
|
||||
continue
|
||||
for item, slot in zip(items, item_slot, strict=True):
|
||||
if not _matches(source, item):
|
||||
if not match(source, item, column):
|
||||
continue
|
||||
if expanded is not None and list(_expansion(source, item)) != expanded:
|
||||
continue
|
||||
@@ -558,14 +587,62 @@ def is_fillable(document: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def assign_items(
|
||||
project_root: str | Path, document: dict[str, Any], items: list[dict[str, Any]]
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
|
||||
"""지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3).
|
||||
|
||||
- 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩
|
||||
- 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값)
|
||||
- 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게)
|
||||
돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림).
|
||||
"""
|
||||
from common_util.common_util_summary_item import snapshot_folder_name
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import read_snapshot
|
||||
|
||||
doc = copy.deepcopy(document)
|
||||
rules = _unexpand(doc.get("열") or [])
|
||||
out: list[dict[str, Any]] = []
|
||||
unassigned = missing = 0
|
||||
for item in items:
|
||||
chosen = item.get("summary_item")
|
||||
column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None
|
||||
if not chosen or not isinstance(column, dict) or not column.get("id"):
|
||||
unassigned += not chosen
|
||||
missing += bool(chosen)
|
||||
continue
|
||||
new = copy.deepcopy(column)
|
||||
if chosen["layer"] != "system":
|
||||
new["id"] = f"{column['id']}@{snapshot_folder_name(chosen)}"
|
||||
if isinstance(new.get("바인딩"), dict) and new["바인딩"].get("묶음"):
|
||||
new["바인딩"]["묶음"] = new["id"]
|
||||
at = next((i for i, c in enumerate(rules) if c.get("id") == new["id"]), None)
|
||||
if at is None:
|
||||
rules.append(new)
|
||||
else:
|
||||
rules[at] = new
|
||||
out.append({**item, "열": new["id"], "열묶음": _group(new)})
|
||||
doc["열"] = rules
|
||||
notes = []
|
||||
if unassigned:
|
||||
notes.append(f"집계 항목 미지정 {unassigned}건 — 표에 안 들어감")
|
||||
if missing:
|
||||
notes.append(f"집계 항목 복사본 없음 {missing}건 — [저장] 뒤 채워짐")
|
||||
return doc, out, notes
|
||||
|
||||
|
||||
def fill_table(
|
||||
project_root: str | Path,
|
||||
document: dict[str, Any],
|
||||
pipe_lengths: dict[float, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트 설계로 표 문서를 채움 — 저장 안 함."""
|
||||
"""프로젝트 설계로 표 문서를 채움 — 저장 안 함 · 지정 항목 모드(미지정은 빈칸)."""
|
||||
types = structure_type_map()
|
||||
return fill_document(document, collect_items(project_root, pipe_lengths, types), types)
|
||||
items = collect_items(project_root, pipe_lengths, types)
|
||||
doc, chosen, notes = assign_items(project_root, document, items)
|
||||
filled = fill_document(doc, chosen, types, assigned=True)
|
||||
filled["알림"] = [*notes, *filled.get("알림", [])]
|
||||
return filled
|
||||
|
||||
|
||||
def recalc(document: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
Reference in New Issue
Block a user