feat(b08): 구조물도 양식 가져오기 — 3단 목록은 고를 때만, 표는 프로젝트에 박힌 양식만 읽음

- 라이브러리 항목을 목록으로(항목마다 AX-ST-<8hex>.json) · 기본 찰쌓기에 코드 부여
- 가져오기: 로그인한 사람의 개인·회사 단 + 프로그램 기본에서 골라 {프로젝트}/B08_Quantity/library 에 박음 · 그 종류의 고친 식은 비움
- 표·원단위·자재총괄·인계는 박힌 양식 → 없으면 프로그램 기본만 읽음(여는 사람마다 값이 안 갈림)
- [확정] 때 아직 안 박힌 기본 양식을 박음 · 장 머리에 「어느 단에서 가져왔나 · 고친 식 N줄」
- 시험 test_b08_structure_library.py 6개 · 전체 1524 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 18:21:19 +09:00
co-authored by Claude Opus 5
parent 2ffb913c36
commit 4f1521eea9
10 changed files with 618 additions and 15 deletions
@@ -0,0 +1,111 @@
"""구조물도 양식 **라이브러리** — 3단(개인·회사·프로그램) 목록과 프로젝트로 **가져오기** (PLAN 4장).
⛔ 표를 그릴 때 라이브러리를 읽지 않음 — 가져온 양식은 **프로젝트 작업본에 박히고**
(`{프로젝트}/B08_Quantity/library/<code>.json`) 표는 그것만 읽음. 3단은 가져오기 고르개에서만 돎.
여는 사람마다 값이 갈리지 않게 하려는 것(브레인 판정 Ⓑ — 개인 = 로그인한 사람).
⚠ 아직 아무것도 안 가져온 종류는 프로그램 기본을 읽음 — 보는 사람과 무관한 한 벌.
⚠ 저장은 **목록**(항목마다 파일 한 개 · 코드 `AX-ST-<8자리 16진>`, 명세 2장 ②) —
지금은 종류당 하나만 씀(판정 Ⓐ). 고르기 칸은 뒤에 붙임.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
from config.config_system import STORAGE_BASE_DIR
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
TIERS = ("personal", "company", "program")
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
LIBRARY_SUBDIR = "library"
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
dirs: dict[str, Path] = {}
if company_id is not None:
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
if user_id is not None:
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
dirs["company"] = company / LIBRARY_SUBDIR
dirs["program"] = TEMPLATE_DIR
return dirs
def _items(folder: Path) -> list[dict[str, Any]]:
if not folder.is_dir():
return []
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름)."""
return [
{"tier": tier, "code": item.get("code"), "name": item.get("name"), "type_id": type_id}
for tier in TIERS
if tier in dirs
for item in _items(dirs[tier])
if item.get("type_id") == type_id and item.get("code")
]
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
folder = dirs.get(tier)
if folder is None:
return None
return next((item for item in _items(folder) if item.get("code") == code), None)
def project_library_dir(project_root: str | Path) -> Path:
return Path(project_root) / "B08_Quantity" / LIBRARY_SUBDIR
def project_templates(project_root: str | Path | None) -> dict[str, dict[str, Any]]:
"""프로젝트에 박힌 양식 — 종류(type_id)별 하나. **표를 그릴 때 읽는 유일한 양식 자리.**"""
if not project_root:
return {}
return {
str(item["type_id"]): item
for item in _items(project_library_dir(project_root))
if item.get("type_id")
}
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
"""고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나).
⚠ 새 것을 먼저 쓰고 옛 것을 지움 — 쓰다 실패하면 옛 양식이 남아 조용히 기본으로 안 떨어짐.
"""
code = str(item.get("code") or "")
if not CODE_PATTERN.match(code):
raise ValueError(f"항목 코드 모양이 아님: {code!r}")
folder = project_library_dir(project_root)
folder.mkdir(parents=True, exist_ok=True)
body = {**item, "imported_from": tier}
target = folder / f"{code}.json"
target.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8")
for path in folder.glob("*.json"):
if path == target:
continue
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == item.get("type_id"):
path.unlink()
def pin_program_templates(project_root: str | Path) -> int:
"""[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수.
확정은 정본을 굳히는 자리라, 뒤에 프로그램 기본을 고쳐도 확정한 값이 안 흔들려야 함
(브레인 판정). 확정 전(GET 만 한 프로젝트)은 기본을 그대로 읽음 — 정본이 아님.
"""
pinned = project_templates(project_root)
count = 0
for item in _items(TEMPLATE_DIR):
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
import_item(project_root, item, "program")
count += 1
return count
@@ -31,6 +31,14 @@ def load_template(type_id: str) -> dict[str, Any] | None:
return json.loads(path.read_text(encoding="utf-8"))
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
"""프로젝트에 박힌 양식(4장 가져오기) → 없으면 프로그램 기본.
⛔ 개인·회사 단은 여기서 안 읽음 — 가져온 것만 `templates` 로 옴(여는 사람마다 안 갈리게).
"""
return (templates or {}).get(type_id) or load_template(type_id)
def _typed(value: Any, default: Any) -> Any:
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
if isinstance(default, (int, float)) and not isinstance(default, bool):
@@ -150,6 +158,7 @@ def replace_with_templates(
section_modes: dict[float, str] | None,
rubble_base_thickness_m: float | None,
formula_overrides: dict[str, Any] | None = None,
templates: dict[str, Any] | None = None,
) -> None:
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
@@ -166,7 +175,7 @@ def replace_with_templates(
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
for quantity, item in zip(quantities, inputs):
template = load_template(quantity.type_id)
template = template_of(quantity.type_id, templates)
if template is None or not quantity.components:
continue
options = item.get("options") or {}
@@ -196,7 +205,7 @@ def replace_with_templates(
if solved is None:
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
template = load_template(quantity.type_id) or {}
template = template_of(quantity.type_id, templates) or {}
by_seq = {row["seq"]: row for row in body["rows"]}
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
@@ -236,7 +245,11 @@ def replace_with_templates(
quantity.library_item = str(template.get("name") or quantity.type_id)
def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None:
def apply_templates(
payload: dict[str, Any],
settings: dict[str, Any] | None = None,
templates: dict[str, Any] | None = None,
) -> None:
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장).
@@ -250,7 +263,7 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
all_overrides = (settings or {}).get(OVERRIDES_KEY) or {}
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
for sheet in payload.get("sheets") or []:
template = load_template(str(sheet.get("type_id") or ""))
template = template_of(str(sheet.get("type_id") or ""), templates)
if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS:
for row in sheet.get("rows") or []:
row.setdefault("formula", "")
@@ -269,7 +282,7 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
return
solved = evaluate_sheets([body for _sheet, body in targets])
for index, (sheet, body) in enumerate(targets):
template = load_template(str(sheet.get("type_id") or "")) or {}
template = template_of(str(sheet.get("type_id") or ""), templates) or {}
if solved is None:
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
@@ -281,7 +294,13 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")}
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
sheet["library_item"] = {
"type_id": template.get("type_id"),
"name": template.get("name"),
"code": template.get("code"),
"imported_from": template.get("imported_from"),
}
sheet["formula_sheet"] = body
@@ -1511,6 +1511,7 @@ def build_table(
rubble_base_thickness_m: float | None = None,
use_templates: bool = True,
structure_formulas: dict[str, Any] | None = None,
structure_templates: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
@@ -1519,6 +1520,8 @@ def build_table(
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`).
없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김.
"""
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
@@ -1545,6 +1548,7 @@ def build_table(
section_modes,
rubble_base_thickness_m,
structure_formulas,
structure_templates,
)
violations = verify_no_mix_components(quantities)
+20 -1
View File
@@ -5,6 +5,7 @@
구현할 때 이 라우터를 확장한다.
"""
import asyncio
import logging
from uuid import UUID
@@ -20,7 +21,25 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
@router.post("/{project_id}/quantity/confirm")
async def confirm_quantity(project_id: UUID) -> JSONResponse:
"""수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현)."""
"""수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현).
⚠ 전이 전에 **아직 안 박힌 구조물도 양식을 프로젝트에 박음**(PLAN 4장) — 확정 뒤 프로그램
기본을 고쳐도 확정한 값이 안 흔들리게. 못 박으면 확정하지 않음.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import pin_program_templates
from B08_Quantity.B08_Quantity_Router_StructureSheet import _project_root
project_root = await _project_root(project_id)
try:
if project_root is None:
raise FileNotFoundError("프로젝트 저장 폴더 없음")
await asyncio.to_thread(pin_program_templates, project_root)
except Exception:
logger.exception("B08 수량 확정 실패(양식 박기): project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물도 양식을 프로젝트에 박지 못했습니다."},
)
pool = get_db_pool()
async with pool.acquire() as connection:
try:
@@ -32,6 +32,7 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
ground_types_from_designs,
@@ -157,6 +158,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
settings.get("rubble_base_thickness_m"),
# 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤).
structure_formulas=settings.get("structure_formula_overrides"),
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
@@ -210,6 +213,7 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
except Exception:
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
@@ -252,6 +256,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
@@ -14,14 +14,15 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
from typing import Any, Literal
from uuid import UUID
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
@@ -51,6 +52,7 @@ def project_structure_sheets(
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
모듈 맨 위에서 부르면 맞물린다.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
@@ -59,6 +61,7 @@ def project_structure_sheets(
structures, names, skipped = _collect_structures(project_root)
settings = quantity_settings(project_root)
templates = project_templates(project_root)
unit_table = build_unit_table(
structures,
names,
@@ -66,10 +69,11 @@ def project_structure_sheets(
ground_types,
settings.get("rubble_base_thickness_m"),
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=templates,
)
payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings)
apply_templates(payload, settings, templates)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return payload
@@ -141,10 +145,11 @@ async def put_structure_sheet_formulas(
⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장).
⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
OVERRIDES_KEY,
load_template,
save_sheet_overrides,
template_of,
)
from common_util.common_util_project_settings import quantity_settings, save_section
@@ -153,7 +158,11 @@ async def put_structure_sheet_formulas(
return _not_found()
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
template = load_template(str((picked or {}).get("type_id") or "")) if picked else None
template = (
template_of(str(picked.get("type_id") or ""), project_templates(project_root))
if picked
else None
)
if picked is None or template is None:
return JSONResponse(
status_code=404,
@@ -259,6 +268,94 @@ async def put_structure_sheet_spec(
)
@router.get("/{project_id}/quantity/structure-sheets/library")
async def get_structure_library(
project_id: UUID, type_id: str, session: dict[str, Any] = Depends(verify_session)
) -> JSONResponse:
"""가져오기 고르개 — **로그인한 사람**의 개인·회사 단과 프로그램 기본에서 그 종류의 항목.
⛔ 3단을 읽는 곳은 여기와 가져오기뿐 — 표를 그릴 때는 안 읽음(PLAN 4장 · 판정 Ⓑ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
list_items,
project_templates,
tier_dirs,
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
current = project_templates(project_root).get(type_id) or {}
return JSONResponse(
content={
"status": "success",
"items": await asyncio.to_thread(list_items, dirs, type_id),
"current": {"code": current.get("code"), "imported_from": current.get("imported_from")},
}
)
class LibraryImportRequest(BaseModel):
"""가져올 항목 하나 — 단과 코드로 고름(이름은 겹칠 수 있음)."""
model_config = ConfigDict(extra="forbid")
type_id: str = Field(min_length=1, max_length=100)
tier: Literal["personal", "company", "program"]
code: str = Field(pattern=r"^AX-ST-[0-9a-f]{8}$")
@router.put("/{project_id}/quantity/structure-sheets/library/import")
async def put_structure_library_import(
project_id: UUID,
payload: LibraryImportRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""고른 항목을 **프로젝트 작업본에 박고** 그 종류의 고친 식을 비움.
⚠ 고친 식을 비우는 까닭 — 새 양식과 줄 차례가 안 맞을 수 있음. 묻는 것은 화면 몫.
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
find_item,
import_item,
tier_dirs,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY
from common_util.common_util_project_settings import quantity_settings, save_section
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
dirs = tier_dirs(session.get("company_id"), session.get("user_id"))
item = await asyncio.to_thread(find_item, dirs, payload.tier, payload.code)
if item is None or item.get("type_id") != payload.type_id:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "가져올 양식 항목을 찾지 못했습니다."},
)
await asyncio.to_thread(import_item, project_root, item, payload.tier)
current = quantity_settings(project_root).get(OVERRIDES_KEY) or {}
cleared = len(current.get(payload.type_id) or {})
if cleared:
rest = {key: rows for key, rows in current.items() if key != payload.type_id}
await asyncio.to_thread(
save_section,
project_root,
"quantity",
{OVERRIDES_KEY: rest},
replace_keys=[OVERRIDES_KEY],
)
return JSONResponse(
content={
"status": "success",
"code": payload.code,
"imported_from": payload.tier,
"cleared_formulas": cleared,
}
)
@router.get("/{project_id}/quantity/structure-sheets")
async def get_structure_sheets(project_id: UUID) -> JSONResponse:
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
+37 -2
View File
@@ -21,6 +21,7 @@ import {
type StandardSheetSpec,
type StandardSpecResult,
} from "./B08_Quantity_UI_StructureSheet_Spec";
import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library";
export interface StructureSheetRow {
no: number;
@@ -104,7 +105,13 @@ export interface StructureSheet extends StandardSheetSpec {
notes: string[];
unpriced_rows: string[];
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
library_item?: { type_id: string; name: string };
/** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */
library_item?: {
type_id: string;
name: string;
code?: string | null;
imported_from?: string | null;
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
}
@@ -394,6 +401,11 @@ function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
return scroller;
}
/** 사용자가 고친 식 줄 수 — 장 머리 「그 뒤 고쳤나」와 가져오기 전 물음에 씀. */
function editedRows(sheet: StructureSheet): number {
return sheet.rows.filter((row) => row.source === "user").length;
}
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */
function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement {
const main = el("div", "b08-sheet__main");
@@ -407,7 +419,9 @@ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLEleme
"",
`${sheet.title}${sheet.member_count}개소${total} · ` +
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
(sheet.library_item ? `양식 「${sheet.library_item.name}` : "양식 없음(지금 전개)"),
(sheet.library_item
? libraryLabel(sheet.library_item, editedRows(sheet))
: "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
@@ -541,6 +555,27 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
initialNotes,
),
);
if (sheet.library_item) {
aside.append(
buildLibraryPanel(
projectId,
sheet.library_item.type_id,
sheet.library_item.code ?? null,
() => {
const edited = editedRows(sheet);
const lost = [edited ? `고친 식 ${edited}` : "", dirty ? "저장 안 한 식" : ""]
.filter(Boolean)
.join(" · ");
if (!lost) return true;
if (!window.confirm(`가져오면 이 종류의 ${lost}이 비워짐 — 가져올까요?`))
return false;
dirty = false;
return true;
},
(after) => load(sheet.members[0]?.structure_id ?? null, after),
),
);
}
const editor = sheet.formula_sheet
? formulaTable(
sheet,
@@ -0,0 +1,134 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Library.ts
* (PLAN 4) · +
* ** **.
*
* [ ] ( ).
* () (`b08-spec*`) .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
interface LibraryItem {
tier: string;
code: string;
name: string;
}
/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */
export function libraryLabel(
item: { name: string; imported_from?: string | null },
editedRows: number,
): string {
const from = item.imported_from
? `${TIER_LABELS[item.imported_from] ?? item.imported_from}에서 가져옴`
: "기본 · 가져오기 전";
return `양식 「${item.name}」(${from})${editedRows ? ` · 고친 식 ${editedRows}` : ""}`;
}
function libraryUrl(projectId: string, tail: string): string {
return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library${tail}`;
}
async function readJson<T>(response: Response): Promise<T> {
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/**
* . `confirmTake`
* , · .
*/
export function buildLibraryPanel(
projectId: string,
typeId: string,
currentCode: string | null,
confirmTake: () => boolean,
onImported: (notes: string[]) => Promise<void>,
): HTMLElement {
const panel = document.createElement("div");
panel.className = "b08-spec ui-sidebar-section";
const title = document.createElement("h3");
title.className = "b08-spec__title";
title.textContent = "양식 가져오기";
const scope = document.createElement("p");
scope.className = "b08-spec__scope";
scope.textContent = "가져온 양식은 이 프로젝트에 박혀 누가 열어도 같은 값으로 섭니다.";
const status = document.createElement("p");
status.className = "b08-spec__scope";
const list = document.createElement("select");
list.className = "b08-spec__input";
list.hidden = true;
const load = document.createElement("button");
load.type = "button";
load.className = "b08-quantity__tab";
load.textContent = "목록 보기";
const take = document.createElement("button");
take.type = "button";
take.className = "b08-spec__save";
take.textContent = "가져오기";
take.hidden = true;
load.addEventListener("click", () => {
void (async () => {
load.disabled = true;
status.textContent = "목록 받는 중…";
try {
const { items } = await readJson<{ items: LibraryItem[] }>(
await fetch(libraryUrl(projectId, `?type_id=${encodeURIComponent(typeId)}`), {
credentials: "include",
}),
);
list.replaceChildren(
...items.map((item) => {
const option = document.createElement("option");
option.value = `${item.tier}|${item.code}`;
const now = item.code === currentCode ? " (지금)" : "";
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · ${item.name}${now}`;
return option;
}),
);
list.hidden = take.hidden = items.length === 0;
status.textContent = items.length ? "" : "가져올 항목이 없음";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "목록을 받지 못함";
} finally {
load.disabled = false;
}
})();
});
take.addEventListener("click", () => {
const [tier, code] = list.value.split("|");
if (!tier || !code || !confirmTake()) return;
void (async () => {
take.disabled = true;
status.textContent = "가져오는 중…";
try {
const result = await readJson<{ cleared_formulas: number }>(
await fetch(libraryUrl(projectId, "/import"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type_id: typeId, tier, code }),
}),
);
const cleared = result.cleared_formulas
? ` · 고친 식 ${result.cleared_formulas}줄 비움`
: "";
// 프로젝트 작업본이 바뀌었으니 표를 **다시 받아** 그림.
await onImported([`양식을 가져왔습니다${cleared}.`]);
} catch (error) {
status.textContent = error instanceof Error ? error.message : "가져오지 못함";
take.disabled = false;
}
})();
});
panel.append(title, scope, load, list, take, status);
return panel;
}
+6 -1
View File
@@ -1,5 +1,6 @@
{
"schema_version": 1,
"code": "AX-ST-56e81a2c",
"library_tier": "program",
"item_kind": "form",
"type_id": "masonry_wet",
@@ -31,7 +32,11 @@
"default": 0
},
"기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" },
"BLIND": { "label": "버림 콘크리트(넣음·안 넣음)", "option": "blinding_concrete", "default": "넣음" },
"BLIND": {
"label": "버림 콘크리트(넣음·안 넣음)",
"option": "blinding_concrete",
"default": "넣음"
},
"SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" },
"MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" },
"HOLE_DIA": { "label": "물구멍 지름(㎜)", "option": "weep_hole_diameter_mm", "default": 50 },
@@ -0,0 +1,174 @@
"""구조물도 양식 라이브러리 — 가져오기·박기 (2026-09-13, PLAN 4장 ①).
겨누는
가져오기 전은 프로그램 기본 · 머리 가져오기
로그인한 사람의 개인 단에서 가져오면 **프로젝트에 박히고** 원단위·자재총괄도 양식을
박힌 뒤에는 라이브러리를 읽음 개인 파일을 지우거나 다른 사람이 열어도 값이 같음
가져오면 종류의 고친 식이 비워짐
[확정] 박힌 기본 양식을 박음 · 코드 모양이 아니면 거절
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
from common_util.common_util_auth import verify_session # noqa: E402
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
PERSONAL_CODE = "AX-ST-0badc0de"
@pytest.fixture()
def storage(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
base = tmp_path / "storage"
monkeypatch.setattr(library_module, "STORAGE_BASE_DIR", str(base))
# 개인 단(회사 7 · 사람 42)에 모르터 식을 두 배로 고친 찰쌓기 한 벌.
mine = load_template("masonry_wet")
for row in mine["rows"]:
if row["name"] == "모르터":
row["formula"] = "A*0.018"
mine.update(code=PERSONAL_CODE, library_tier="personal", name="돌쌓기(찰) 내 것")
folder = base / "7" / "42" / "library"
folder.mkdir(parents=True)
(folder / f"{PERSONAL_CODE}.json").write_text(json.dumps(mine), encoding="utf-8")
return base
@pytest.fixture()
def project(tmp_path: Path) -> Path:
root = tmp_path / "project"
root.mkdir()
wall = StructureInstance.model_validate(
{
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 100.0,
"end_m": 110.0,
"options": {"height_m": 2.5, "back_len_cm": 45},
}
)
save_structures(str(root), [wall], base_revision=0)
return root
@pytest.fixture()
def client(project: Path, storage: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
async def fake_root(project_id):
return str(project)
async def no_route(project_id):
return {}
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(material_module, "_section_modes", no_route)
monkeypatch.setattr(material_module, "_ground_types", no_route)
app = FastAPI()
app.include_router(router_module.router)
app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 42}
return TestClient(app)
def _sheet(client: TestClient) -> dict:
response = client.get(SHEETS)
assert response.status_code == 200, response.text
return response.json()["sheets"][0]
def _mortar(sheet: dict) -> dict:
return next(row for row in sheet["rows"] if row["name"] == "모르터")
def test_가져오기_전은_프로그램_기본(client: TestClient) -> None:
item = _sheet(client)["library_item"]
assert item["imported_from"] is None
assert item["code"] == load_template("masonry_wet")["code"]
def test_개인_단에서_가져오면_박히고_라이브러리를_다시_안_읽는다(
client: TestClient, project: Path, storage: Path
) -> None:
before = _mortar(_sheet(client))["unit_amount"]
listed = client.get(f"{SHEETS}/library", params={"type_id": "masonry_wet"}).json()["items"]
assert [item["tier"] for item in listed] == ["personal", "program"]
taken = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE},
)
assert taken.status_code == 200, taken.text
sheet = _sheet(client)
assert sheet["library_item"]["imported_from"] == "personal"
assert _mortar(sheet)["unit_amount"] == pytest.approx(before * 2)
# 가져온 식은 「사용자 식」이 아니라 그 양식의 식.
assert _mortar(sheet)["source"] == "library"
# 원단위·자재총괄 창구도 박힌 양식을 봄.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
structures, names, _ = material_module._collect_structures(str(project))
table = build_table(
structures,
names,
structure_templates=library_module.project_templates(project),
)
mortar = next(c for c in table["structures"][0]["components"] if c["name"] == "모르터")
assert mortar["amount"] == pytest.approx(_mortar(sheet)["unit_amount"] * 10.0)
# ⛔ 개인 파일이 사라지고 다른 사람이 열어도 값이 그대로.
(storage / "7" / "42" / "library" / f"{PERSONAL_CODE}.json").unlink()
client.app.dependency_overrides[verify_session] = lambda: {"company_id": 7, "user_id": 99}
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(before * 2)
def test_가져오면_그_종류의_고친_식이_비워진다(client: TestClient, project: Path) -> None:
sheet = _sheet(client)
seq = _mortar(sheet)["no"]
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]},
)
program_code = load_template("masonry_wet")["code"]
taken = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "program", "code": program_code},
)
assert taken.json()["cleared_formulas"] == 1
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
assert _sheet(client)["library_item"]["imported_from"] == "program"
def test_없는_항목_가져오기는_404(client: TestClient) -> None:
missing = client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "company", "code": "AX-ST-12345678"},
)
assert missing.status_code == 404
def test_확정_때_기본_양식을_박는다(project: Path) -> None:
assert library_module.pin_program_templates(project) >= 1
pinned = library_module.project_templates(project)["masonry_wet"]
assert pinned["imported_from"] == "program"
assert library_module.pin_program_templates(project) == 0 # 이미 박힌 것은 그대로
def test_코드_모양이_아니면_박지_않는다(project: Path) -> None:
with pytest.raises(ValueError):
library_module.import_item(project, {"type_id": "masonry_wet", "code": "../x"}, "personal")