feat(M02): 구조물집계표조합 표 — 구조물 열마다 한 줄 · 키 SS · 집계표 [저장] 때 서버가 맞춤
- resources/master_template/구조물집계표조합.json (그룹 · 판 · 비고 · 다음 · 줄) · 지금 열 51개로 처음 만듦(SS000001~SS000051) - 줄 칸: 키 · 열 · 종류 · 분류1~3 · 단위 · 일위대가 · 도번 · 면적번 · 재료번 · 바인딩 · 펼침 · 꼴=수 열만 - 새 열 = 새 SS 키 · 지운 열 = 줄 지움(키 다시 안 씀) · 고친 열 = 칸 갱신 · 새 프로젝트 복사에도 실림 - GET /api/m02/structure-combos · Api_Fetch fetchStructureCombos() - 시험: 견본 표로 키 유지 · 새 열 · 지운 열 · 글 열 제외 · 키대장 머리글과 안 겹침 · 프로젝트 복사 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUypcnp5d1gU2aeKh9F2H7
This commit is contained in:
@@ -128,6 +128,31 @@ export interface StructureNumbers {
|
||||
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
|
||||
call("/structures/numbers");
|
||||
|
||||
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
|
||||
export interface StructureCombo extends StructureNumbers {
|
||||
키: string;
|
||||
열: string;
|
||||
종류: string | null;
|
||||
분류1: string | null;
|
||||
분류2: string | null;
|
||||
분류3: string | null;
|
||||
단위: string | null;
|
||||
일위대가: string | null;
|
||||
바인딩: unknown;
|
||||
펼침: unknown;
|
||||
}
|
||||
|
||||
export interface StructureCombos {
|
||||
그룹: string;
|
||||
판: number;
|
||||
비고: string;
|
||||
다음: number;
|
||||
줄: StructureCombo[];
|
||||
}
|
||||
|
||||
/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */
|
||||
export const fetchStructureCombos = (): Promise<StructureCombos> => call("/structure-combos");
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
|
||||
@@ -37,6 +37,12 @@ def get_structure_numbers() -> dict:
|
||||
return _call(store.structure_numbers)
|
||||
|
||||
|
||||
@router.get("/structure-combos")
|
||||
def get_structure_combos() -> dict:
|
||||
"""구조물집계표조합 — 구조물 열마다 줄 하나(키 SS · 제목행 · 번호 · 바인딩) · 읽기만."""
|
||||
return _call(store.read_combos)
|
||||
|
||||
|
||||
@router.post("/structures/{column}")
|
||||
def post_structure(column: str) -> dict:
|
||||
"""구조물 도면 새로 — 열 id 하나 · 도번 자동 · 빈 면적산출 · 재료산출도 · 이미 있으면 409."""
|
||||
|
||||
@@ -120,6 +120,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]:
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게
|
||||
prune_removed_structures()
|
||||
sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸
|
||||
return info
|
||||
|
||||
|
||||
@@ -369,3 +370,59 @@ def backfill_missing_structures() -> list[str]:
|
||||
for cid in _column_ids():
|
||||
made += [f"{kind}/{cid}" for kind in _KEY if _ensure(kind, cid)]
|
||||
return made
|
||||
|
||||
|
||||
# ── 구조물집계표조합 ─────────────────────
|
||||
# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) ·
|
||||
# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억)
|
||||
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("일위대가"),
|
||||
**{field: numbers.get(field, "") for _, field, _, _ in PARTS},
|
||||
"바인딩": col.get("바인딩"),
|
||||
"펼침": col.get("펼침"),
|
||||
}
|
||||
|
||||
|
||||
def sync_combos() -> dict[str, Any]:
|
||||
"""조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 ·
|
||||
줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀."""
|
||||
path = FOLDER / 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, "줄": rows}
|
||||
if doc != old:
|
||||
atomic_write_json(path, doc)
|
||||
return doc
|
||||
|
||||
|
||||
def read_combos() -> dict[str, Any]:
|
||||
"""조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦."""
|
||||
path = FOLDER / COMBO_FILE
|
||||
return json.loads(path.read_bytes()) if path.is_file() else sync_combos()
|
||||
|
||||
@@ -31,6 +31,7 @@ KINDS = ("table", "drawing", "structure", "area", "material")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
COMBO_FILE = "구조물집계표조합.json" # 층 뿌리의 한 파일(종류 폴더 밖)
|
||||
|
||||
#: 시스템 층 뿌리 — 시험이 임시 폴더로 바꿈.
|
||||
SYSTEM_ROOT = config_system.PROJECT_ROOT / "resources" / "master_template"
|
||||
@@ -317,6 +318,7 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic
|
||||
프로젝트 만들 때 · 옛 프로젝트 넣기(`only_missing`) 둘 다 이 길.
|
||||
|
||||
`_initial/` 은 늘 없는 것만 더함 — 이미 있으면 절대 덮지 않음.
|
||||
구조물집계표조합(층 뿌리의 한 파일)도 같이 실림.
|
||||
"""
|
||||
return {
|
||||
"작업본": copy_templates(
|
||||
@@ -324,13 +326,27 @@ def seed_project(project_root: str | Path, *, only_missing: bool = False) -> dic
|
||||
project_dir(project_root),
|
||||
source_layer="system",
|
||||
only_missing=only_missing,
|
||||
),
|
||||
)
|
||||
+ _copy_combo(project_dir(project_root), only_missing),
|
||||
"초기": copy_templates(
|
||||
system_dir(), initial_dir(project_root), source_layer="system", only_missing=True
|
||||
),
|
||||
)
|
||||
+ _copy_combo(initial_dir(project_root), True),
|
||||
}
|
||||
|
||||
|
||||
def _copy_combo(target_dir: Path, only_missing: bool) -> list[str]:
|
||||
source = system_dir() / COMBO_FILE
|
||||
target = target_dir / COMBO_FILE
|
||||
if not source.is_file() or (only_missing and target.exists()):
|
||||
return []
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.tmp")
|
||||
shutil.copyfile(source, temporary)
|
||||
temporary.replace(target)
|
||||
return [target.stem]
|
||||
|
||||
|
||||
def reset_project(
|
||||
project_root: str | Path, *, kind: str | None = None, name: str | None = None
|
||||
) -> list[str]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
"""구조물집계표조합 — 서버가 집계표 [저장] 때 맞추는 장부(키 SS · 열마다 한 줄).
|
||||
|
||||
견본 표(`write_sample_table`)로만 봄 — 실제 마스터 양식은 안 읽음.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TABLE = "/api/m02/templates/table/구조물집계표"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _save_table(client, change) -> None:
|
||||
got = client.get(TABLE).json()
|
||||
change(got["문서"])
|
||||
assert client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}).status_code == 200
|
||||
|
||||
|
||||
def _keys(client) -> dict[str, str]:
|
||||
return {row["열"]: row["키"] for row in client.get("/api/m02/structure-combos").json()["줄"]}
|
||||
|
||||
|
||||
def test_처음_한_번_만듦_글_열_빼고_칸은_집계표_그대로(client, tmp_path):
|
||||
assert not (tmp_path / store.COMBO_FILE).exists()
|
||||
doc = client.get("/api/m02/structure-combos").json()
|
||||
assert (doc["그룹"], doc["다음"]) == ("구조물집계표조합", 14)
|
||||
rows = doc["줄"]
|
||||
assert [r["열"] for r in rows][:3] == ["rv", "c1", "pv_b"]
|
||||
assert {"no", "sta", "memo"}.isdisjoint(r["열"] for r in rows) # 글 열 빼기
|
||||
assert (rows[0]["키"], rows[-1]["키"]) == ("SS000001", "SS000013")
|
||||
assert rows[-1]["열"] == "h_rip_m"
|
||||
fb = next(r for r in rows if r["열"] == "fb")
|
||||
assert (fb["종류"], fb["분류1"], fb["분류2"], fb["분류3"]) == (
|
||||
"세월교",
|
||||
"(관경 × 련수마다)",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert (tmp_path / store.COMBO_FILE).is_file()
|
||||
|
||||
|
||||
def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지(client):
|
||||
before = _keys(client)
|
||||
|
||||
def change(table):
|
||||
cols = table["열"]
|
||||
cols[:] = [c for c in cols if c["id"] != "h_rip_m"] # 맨 끝 키 SS000013 열 지움
|
||||
rv = next(c for c in cols if c["id"] == "rv")
|
||||
rv["머리"] = ["돌기슭막이", "메쌓기", "1.2m", "기초유"]
|
||||
rv.update(단위="m", 일위대가="UA000004", 펼침=True, 바인딩={"종류": "revetment"})
|
||||
cols.insert(3, {"id": "new1", "머리": ["옹벽", None, None, None], "단위": "m", "꼴": "수"})
|
||||
|
||||
_save_table(client, change)
|
||||
doc = client.get("/api/m02/structure-combos").json()
|
||||
rows = {r["열"]: r for r in doc["줄"]}
|
||||
assert "h_rip_m" not in rows
|
||||
assert rows["new1"]["키"] == "SS000014" # 지운 열의 SS000013 은 다시 안 씀
|
||||
assert [r["열"] for r in doc["줄"]][:4] == ["rv", "new1", "c1", "pv_b"] # 집계표 차례
|
||||
assert all(rows[c]["키"] == k for c, k in before.items() if c != "h_rip_m")
|
||||
rv = rows["rv"]
|
||||
assert (rv["분류1"], rv["분류3"], rv["단위"], rv["일위대가"]) == (
|
||||
"메쌓기",
|
||||
"기초유",
|
||||
"m",
|
||||
"UA000004",
|
||||
)
|
||||
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
|
||||
# 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"]
|
||||
assert rows["new1"]["도번"].startswith("SD")
|
||||
|
||||
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로
|
||||
_save_table(client, lambda table: None)
|
||||
again = client.get("/api/m02/structure-combos").json()
|
||||
assert (again["다음"], again["줄"]) == (15, doc["줄"])
|
||||
|
||||
|
||||
def test_SS_머리글은_키대장과_안_겹침():
|
||||
ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))
|
||||
assert "SS" not in ledger["다음"]
|
||||
assert store._COMBO_HEAD == "SS"
|
||||
|
||||
|
||||
def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch):
|
||||
client.get("/api/m02/structure-combos")
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", tmp_path)
|
||||
copied = layers.seed_project(tmp_path / "project")
|
||||
assert "구조물집계표조합" in copied["작업본"] and "구조물집계표조합" in copied["초기"]
|
||||
target = layers.project_dir(tmp_path / "project") / store.COMBO_FILE
|
||||
assert json.loads(target.read_bytes())["줄"][0]["키"] == "SS000001"
|
||||
Reference in New Issue
Block a user