feat(규격 열 고르기): summary_item spec · 사본이 고른 열 굳힘 · 같은 항목 = 열까지 · B07 한 장 = 항목 + 열 · 쓰는 열 지움 409 (PLAN 704-4)

- summary_item spec(파이썬 · TS 짝) — 없으면 기본 열 · 모르는 키 거절 그대로
- 사본 = 고른 열 수량을 손으로 굳힘 · manifest 규격 · same_item 에 spec(열 바꿈 = 새 사본 · 덮개 열쇠 S:폴더 그대로)
- B07 구조물 도면 키 = 항목 + 열 해시 — 열이 다르면 따로 장
- M02 일위대가 [저장] — 쓰는 사본 있는 열 지움 · 이름 바꿈 409
- 항목 목록 labor.규격(열 · 열마다 합계) — 고르기 창 열 단추
- 시험 test_704_4_spec 7(열 바꿈 뒤 덮개 남음 · 짝 잃음 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:16:06 +09:00
co-authored by Claude Opus 5.5
parent 3028801cd8
commit 425a4a9dfb
9 changed files with 239 additions and 11 deletions
@@ -1,12 +1,14 @@
"""B07 구조물 도면(376 · 요구 241-1) — B05 · B06 배치에 연결된 구조물 조합 도면 목록 · 단건.
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) — 같은 조합을 여러 배치가 써도 한 장 ·
조합 키 = 항목 사본 폴더 이름(`snapshot_folder_name`) + 규격 열(704-4 · `-s해시`) — 같은 조합 ·
같은 열을 여러 배치가 써도 한 장 · 열이 다르면 따로(표제 = 항목 이름 「… (H=2.0)」) ·
더한장은 장마다 · id `structure_{키}[_{장id}]` · 차례 = 도번 · 도면 = 프로젝트 사본 `도면.json` +
얹는 도각(M02 `structure_frame` · 도번 · 도면명). 고치기는 M02 · B08 — 여기는 읽기만.
"""
from __future__ import annotations
import hashlib
import logging
import re
from pathlib import Path
@@ -38,6 +40,8 @@ def structure_sheets(project_root: Path) -> list[dict[str, Any]]:
if "도면" not in copy["부분"] or not item.get("key"):
continue
key = snapshot_folder_name(item)
if item.get("spec"): # 규격 열(704-4) — 열 이름은 아무 글이라 id 에는 해시만
key += f"-s{hashlib.sha1(str(item['spec']).encode()).hexdigest()[:8]}"
if key in docs:
continue
doc = read_json(snapshot.structures_dir(project_root) / copy["폴더"] / "도면.json")
+5 -1
View File
@@ -86,10 +86,11 @@ def now_text() -> str:
def same_item(manifest: Any, item: dict[str, Any]) -> bool:
"""같은 항목 · 같은 규격 열(704-4 · 열이 바뀌면 다른 사본 · 덮개는 폴더 열쇠라 그대로 남음)."""
origin = manifest.get("원본") if isinstance(manifest, dict) else None
if not isinstance(origin, dict):
return False
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key"))
return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key", "spec"))
def origin_of(item: dict[str, Any]) -> dict[str, Any]:
@@ -142,6 +143,9 @@ def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dic
atomic_write_json(target / name, content)
stamp = now_text()
source = version_of(source_files(files))
spec = ((files.get(LABOR_FILE) or {}).get("줄") or {}).get("규격") # 굳힌 규격 열(704-4)
if isinstance(spec, dict):
extra = {**extra, "규격": {"이름": spec.get("이름"), "열": spec.get("기본")}}
manifest = {
"원본": origin_of(item),
"판": source,
+5 -2
View File
@@ -2,7 +2,8 @@
모으는 것 = `{파일 이름: 내용}`:
`집계.json` SS 줄 + 열 정의 · `도면.json` SD · `면적.json` SA ·
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄)
`일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 ·
규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4)
+ 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) +
참조사본(단가승격 · 사전조합을 값으로 굳힘) ·
`단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT)
@@ -146,7 +147,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
files[name] = part
doc = read_json(root / LABOR_KIND / f"{column_id}.json") if column_id else None
if isinstance(doc, dict):
labor = labor_doc_rows(doc)
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
labor = labor_doc_rows(labor_spec.freeze(doc, item.get("spec"))) # 고른 규격 열(704-4)
else:
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
if labor is not None:
+22 -1
View File
@@ -132,7 +132,7 @@ def freeze(doc: dict, col: Any = None) -> dict:
use = column(doc, col)
if use is None:
return doc
got = at(doc, use)
got = {k: v for k, v in at(doc, use).items() if k != "계산값"} # 기본 열 계산값은 버림
return {**got, "규격": {"이름": doc["규격"].get("이름"), "열": [use], "기본": use, "수량": {}}}
@@ -161,3 +161,24 @@ def users(layer_root: Path, keys: set[str], cols: set[str]) -> list[str]:
if same:
found.append(f"{path.parents[3].name}/{path.parent.name}")
return found
def guard(path: Path, new: Any, column: str) -> None:
"""[저장] 때 빠진 규격 열(지움 · 이름 바꿈)을 쓰는 프로젝트 사본이 있으면 409 —
지금 층(`store.root`) 그 열 항목(SS 키 · 옛 키) 기준."""
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Source import read_json
old = (read_json(path) or {}).get("규격") if path.is_file() else None
gone = set((old or {}).get("열") or []) - set(((new or {}).get("규격") or {}).get("열") or [])
if not gone:
return
rows = [r for r in store.read_combos().get("줄", []) if str(r.get("열")) == column]
keys = {str(k) for r in rows for k in [r.get("키"), *(r.get("옛키") or [])] if k}
used = users(store.root(), keys, gone)
if used:
raise store.StoreError(
409,
f"규격 열 「{', '.join(sorted(gone))}」 을 쓰는 프로젝트 사본 {len(used)}개 — 지울 수 없음 "
f"({', '.join(used[:3])}{' …' if len(used) > 3 else ''})",
)
@@ -119,6 +119,14 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]:
"기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2)
"단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3)
}
spec = doc.get("규격") if isinstance(doc.get("규격"), dict) else None
if spec and spec.get("열"): # 규격 열(704-4) — 고르기 창 열 단추 · 열마다 합계
sums = calc.get("규격합") or {}
labor["규격"] = {
"이름": spec.get("이름"),
"열": [{"이름": c, "합계": _number(sums.get(c))} for c in spec["열"]],
"기본": spec.get("기본"),
}
return {"수정일": stamps, "labor": labor}
@@ -166,6 +166,10 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
if kind == "table" and name == STRUCTURE_TABLE:
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
doc = _server(kind, doc)
if kind == "labor": # 쓰는 규격 열 지움 막음(704-4)
from M02_MasterTemplete import M02_Labor_Spec as labor_spec
labor_spec.guard(path, doc, name)
fresh: list[dict[str, Any]] = []
with _LOCK:
have = version_of(path.read_bytes()) if path.is_file() else ""
+13 -4
View File
@@ -1,7 +1,10 @@
"""구조물 집계표 항목 참조 `summary_item` — 모양 검사 · 스냅샷 폴더 이름 (PLAN 21-1).
summary_item = {layer: "system"|"company"|"personal",
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"}
owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…",
spec?: "2.0"}
`spec`(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(옛 기록 · 규격 없는 항목).
항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001`
(머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새
@@ -28,15 +31,17 @@ _OWNER = {
"personal": re.compile(r"^\d+/\d+$"),
}
_NAME_MAX = 200
_SPEC_MAX = 20 # 규격 열 이름 상한(`M02_Labor_Spec.NAME_MAX`)
def parse_summary_item(value: Any) -> dict[str, Any] | None:
"""모양이 맞으면 `{layer, owner, key, name}` 새 dict · 빈 값은 None · 틀리면 ValueError."""
"""모양이 맞으면 `{layer, owner, key, name, spec?}` 새 dict · 빈 값은 None ·
틀리면 ValueError."""
if value is None:
return None
if not isinstance(value, dict):
raise ValueError("집계 항목(summary_item)은 객체여야 합니다.")
extra = set(value) - {"layer", "owner", "key", "name"}
extra = set(value) - {"layer", "owner", "key", "name", "spec"}
if extra:
raise ValueError(f"집계 항목에 모르는 칸이 있습니다: {', '.join(sorted(extra))}")
layer = value.get("layer")
@@ -59,7 +64,11 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None:
name = value.get("name")
if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX:
raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.")
return {"layer": layer, "owner": owner, "key": key, "name": name}
spec = value.get("spec")
if spec is not None and (not isinstance(spec, str) or not 0 < len(spec.strip()) <= _SPEC_MAX):
raise ValueError(f"집계 항목 spec(규격 열)은 1~{_SPEC_MAX}자 글입니다.")
out = {"layer": layer, "owner": owner, "key": key, "name": name}
return {**out, "spec": spec.strip()} if spec is not None else out
#: 옵션 그룹 id(PLAN 46 · `M02_SO_Groups.GROUP_ID` 와 같은 규칙)
+7 -2
View File
@@ -3,7 +3,9 @@
* 구조물 집계표 항목 참조(PLAN 21-1) — 구조물 · 관 지점이 「어느 집계표 항목」인지 가리키는 값.
*
* summary_item: { layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}",
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m" }
* key: "SS000004", name: "돌기슭막이 메쌓기 1.2m", spec?: "2.0" }
*
* spec(PLAN 704-4) = 그 항목 일위대가 규격 열 이름 — 없으면 기본 열(서버 `parse_summary_item` 과 짝).
*
* 없으면 null(= 미지정 · 집계표에 안 들어감). 옵션(options) 밖의 칸이다.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
@@ -16,6 +18,8 @@ export interface SummaryItem {
owner: string | null;
key: string;
name: string;
/** 규격 열 이름(704-4) — 없으면 기본 열 */
spec?: string;
}
const LAYERS: readonly string[] = ["system", "company", "personal"];
@@ -30,7 +34,8 @@ export function isSummaryItem(value: unknown): value is SummaryItem {
(item.owner === null || typeof item.owner === "string") &&
typeof item.key === "string" &&
item.key !== "" &&
typeof item.name === "string"
typeof item.name === "string" &&
(item.spec === undefined || typeof item.spec === "string")
);
}
+170
View File
@@ -0,0 +1,170 @@
"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from common_util.common_util_summary_item import parse_summary_item
from config import config_system
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import SAMPLE
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
SPEC = {
"이름": "H",
"열": ["1.5", "2.5"],
"기본": "1.5",
"수량": {"GF000219": {"1.5": 2, "2.5": 3}},
}
LABOR = {
"종류": "일위대가",
"열": "ms",
"단위": "m",
"담은로직": [
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통
],
"자재": [],
"규격": SPEC,
}
# ── summary_item spec ──────────────────────────────────────────────────────
def test_summary_item_spec_있음_없음():
assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"}
assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로
with pytest.raises(ValueError, match="모르는 칸"):
parse_summary_item({**MS, "열": "2.5"})
for bad in ("", "x" * 21, 2.5):
with pytest.raises(ValueError, match="spec"):
parse_summary_item({**MS, "spec": bad})
# ── 굳히기 ─────────────────────────────────────────────────────────────────
def test_굳히기_고른_열_지운_열_옛_문서():
got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5")
assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5}
assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}}
assert "계산값" not in got
assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본
old = {k: v for k, v in LABOR.items() if k != "규격"}
assert spec.freeze(old, "2.5") is old
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
@pytest.fixture
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE)
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
(system / "labor").mkdir()
(system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8")
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
return {"system": system, "root": root}
def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]:
rows = [
{
"structure_id": f"st{n}",
"type_id": "masonry_dry",
"placement": "interval",
"chainage_m": 85 + n * 100,
"start_m": 80 + n * 100,
"end_m": 90 + n * 100,
"options": {"height_m": 2.0, "length_m": 10},
"summary_item": {**MS, **({"spec": s} if s else {})},
}
for n, s in enumerate(specs)
]
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
'{"points": []}', encoding="utf-8"
)
return snap.sync_snapshots(root)
def _block(root: Path, owner: str) -> tuple[dict, dict]:
got = daega_inputs(root)
return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner])
def test_사본이_고른_열을_굳힘_manifest_규격(world):
root = world["root"]
assert _save(root, ["2.5"])["만듦"] == ["st0"]
folder = snap.structures_dir(root) / "st0"
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"]
assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}]
manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5"
assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로
def test_열_바꿈_뒤_덮개_남음(world):
root = world["root"]
_save(root, ["1.5"])
_, before = _block(root, "st0")
cids = [ln["cid"] for ln in before["lines"]]
assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5]
cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침
got = _save(root, ["2.5"])
assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본
after_all, after = _block(root, "st0")
assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(after_all, sheets, {}, cover)
lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"]
assert lines[0]["qty"] == 3 # 새 열 수량
assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로
assert out["daega_orphans"] == []
def test_B07_한_장_판정_항목_더하기_열(world):
root = world["root"]
_save(root, ["1.5", "1.5", "2.5"])
ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")]
assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
def test_열_지움_쓰는_사본_찾기(world):
root = world["root"]
_save(root, ["2.5", None])
assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"]
assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == []
assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == []
def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
root = world["root"]
_save(root, ["2.5"])
path = world["system"] / "labor" / "ms.json"
with store.at(world["system"]):
with pytest.raises(store.StoreError) as got:
spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms")
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
spec.guard(path, {"규격": SPEC}, "ms")