Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 18:37:30 +09:00
10 changed files with 839 additions and 15 deletions
@@ -0,0 +1,149 @@
"""구조물도 양식 **라이브러리** — 3단(개인·회사·프로그램) 목록과 프로젝트로 **가져오기** (PLAN 4장).
⛔ 표를 그릴 때 라이브러리를 읽지 않음 — 가져온 양식은 **프로젝트 작업본에 박히고**
(`{프로젝트}/B08_Quantity/library/<code>.json`) 표는 그것만 읽음. 3단은 가져오기 고르개에서만 돎.
여는 사람마다 값이 갈리지 않게 하려는 것(브레인 판정 Ⓑ — 개인 = 로그인한 사람).
⚠ 아직 아무것도 안 가져온 종류는 프로그램 기본을 읽음 — 보는 사람과 무관한 한 벌.
⚠ 저장은 **목록**(항목마다 파일 한 개 · 코드 `AX-ST-<8자리 16진>`, 명세 2장 ②) —
지금은 종류당 하나만 씀(판정 Ⓐ). 고르기 칸은 뒤에 붙임.
"""
from __future__ import annotations
import json
import re
import secrets
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 _write(folder: Path, item: dict[str, Any]) -> None:
folder.mkdir(parents=True, exist_ok=True)
text = json.dumps(item, ensure_ascii=False, indent=2)
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
type_id = template.get("type_id")
same = [item for item in _items(folder) if item.get("type_id") == type_id]
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
# 고친 식이 곧 이 항목의 식 — 「사용자 식」 표시와 되돌릴 자리는 떼어 냄.
rows = [
{**{k: v for k, v in row.items() if k != "default_formula"}, "source": "library"}
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
return code
def delete_personal(folder: Path, type_id: str) -> int:
"""[내 것 지우기] — 개인 단의 그 종류만 지움. 프로젝트 작업본은 안 바꿈. 지운 수."""
count = 0
for path in folder.glob("*.json") if folder.is_dir() else []:
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == type_id:
path.unlink()
count += 1
return count
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):
@@ -139,6 +147,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` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
@@ -155,7 +164,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 {}
@@ -185,7 +194,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`)로 따로 둠.
@@ -226,7 +235,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장).
@@ -240,7 +253,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", "")
@@ -259,7 +272,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
@@ -271,7 +284,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
@@ -1508,6 +1508,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 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
@@ -1516,6 +1517,8 @@ def build_table(
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`).
없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김.
"""
observed = load_observed_table()
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
@@ -1542,6 +1545,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,167 @@ 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,
}
)
def _personal_dir(session: dict[str, Any]) -> Path | None:
"""로그인한 사람의 개인 단. 회사·사람이 없으면(시스템 관리자 등) `None`."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import tier_dirs
return tier_dirs(session.get("company_id"), session.get("user_id")).get("personal")
def _no_personal() -> JSONResponse:
return JSONResponse(
status_code=403,
content={"status": "error", "message": "개인 라이브러리는 회사에 속한 사용자만 씁니다."},
)
class LibrarySaveRequest(BaseModel):
"""[내 라이브러리에 저장] — 어느 장의 양식을 쓸지."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
async def put_structure_library_personal(
project_id: UUID,
payload: LibrarySaveRequest,
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""그 장의 양식 + 이 프로젝트에서 고친 식을 **로그인한 사람 개인 단**에 한 벌로 씀.
프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
project_templates,
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from common_util.common_util_project_settings import quantity_settings
folder = _personal_dir(session)
if folder is None:
return _no_personal()
project_root = await _project_root(project_id)
if project_root is None:
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)
type_id = str((picked or {}).get("type_id") or "")
template = template_of(type_id, project_templates(project_root)) if picked else None
if template is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "양식이 있는 구조물도 장을 찾지 못했습니다."},
)
overrides = (quantity_settings(project_root).get(OVERRIDES_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@router.delete("/{project_id}/quantity/structure-sheets/library/personal")
async def delete_structure_library_personal(
project_id: UUID, type_id: str, session: dict[str, Any] = Depends(verify_session)
) -> JSONResponse:
"""[내 것 지우기] — 개인 단의 그 종류만 지움. ⛔ 프로젝트에 박힌 것은 안 바꿈(판정 Ⓑ)."""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import delete_personal
folder = _personal_dir(session)
if folder is None:
return _no_personal()
deleted = await asyncio.to_thread(delete_personal, folder, type_id)
return JSONResponse(content={"status": "success", "deleted": deleted})
@router.get("/{project_id}/quantity/structure-sheets")
async def get_structure_sheets(project_id: UUID) -> JSONResponse:
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
+42 -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),
@@ -518,6 +532,9 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
const pane = el("div", "b08-sheet");
const buttons: HTMLButtonElement[] = [];
const show = (index: number, initialNotes: string[] = []): void => {
// 새로 그린 표에는 고친 칸이 없음 — [식 저장] 뒤 다시 그릴 때 「저장 안 한 식」이 남으면
// 나갈 때·다른 장으로 갈 때 헛물음이 뜨고 [내 라이브러리에 저장]이 막힘(2026-09-13 화면 실측).
dirty = false;
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
const sheet = sheets[index];
const aside = el("div", "b08-sheet__aside");
@@ -541,6 +558,29 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
initialNotes,
),
);
if (sheet.library_item) {
aside.append(
buildLibraryPanel({
projectId,
sheetKey: sheet.key,
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
isDirty: () => dirty,
confirmTake: () => {
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;
},
onImported: (after) => load(sheet.members[0]?.structure_id ?? null, after),
}),
);
}
const editor = sheet.formula_sheet
? formulaTable(
sheet,
@@ -0,0 +1,191 @@
/* =============================================================================
* 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;
}
export interface LibraryPanelOptions {
projectId: string;
sheetKey: string;
typeId: string;
currentCode: string | null;
/** 거짓이면 안 가져옴 — 서버가 그 종류의 고친 식을 비우므로 부르는 쪽이 먼저 물음. */
confirmTake: () => boolean;
/** 저장 안 한 식이 있으면 [내 라이브러리에 저장]을 막음 — 저장된 식만 개인 단으로 감. */
isDirty: () => boolean;
onImported: (notes: string[]) => Promise<void>;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
const { projectId, sheetKey, typeId, currentCode, confirmTake, isDirty, onImported } = options;
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;
}
})();
});
// 개인 단 두 단추 — 목록을 다시 받아야 보이므로 끝나면 [목록 보기]를 한 번 누른 것처럼 갱신.
const personal = (label: string, run: () => Promise<string>): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b08-quantity__tab";
button.textContent = label;
button.addEventListener("click", () => {
void (async () => {
button.disabled = true;
try {
status.textContent = await run();
if (!list.hidden) load.click();
} catch (error) {
status.textContent = error instanceof Error ? error.message : `${label} 못함`;
} finally {
button.disabled = false;
}
})();
});
return button;
};
const save = personal("내 라이브러리에 저장", async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
if (
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀")
) {
return "";
}
const result = await readJson<{ edited: number }>(
await fetch(libraryUrl(projectId, "/personal"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey }),
}),
);
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
});
const remove = personal("내 것 지우기", async () => {
if (!window.confirm("내 라이브러리의 이 종류 양식을 지움 — 이 프로젝트 값은 안 바뀜"))
return "";
const result = await readJson<{ deleted: number }>(
await fetch(libraryUrl(projectId, `/personal?type_id=${encodeURIComponent(typeId)}`), {
method: "DELETE",
credentials: "include",
}),
);
return result.deleted ? "내 라이브러리에서 지움" : "지울 내 양식이 없음";
});
const mine = document.createElement("div");
mine.className = "b08-sheet__actions";
mine.append(save, remove);
panel.append(title, scope, load, list, take, mine, status);
return panel;
}
+7 -1
View File
@@ -1,5 +1,7 @@
{
"schema_version": 1,
"code": "AX-ST-56e81a2c",
"pum_edition": "2026-01-01",
"library_tier": "program",
"item_kind": "form",
"type_id": "masonry_wet",
@@ -31,7 +33,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,221 @@
"""구조물도 양식 라이브러리 — 가져오기·박기 (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_내_라이브러리에_저장은_고친_식을_담고_프로젝트는_안_바꾼다(
client: TestClient, project: Path, storage: Path
) -> None:
"""PLAN 4장 ②③ — 개인 단에 한 벌(같은 종류는 덮어씀) · 지우기도 개인 단만."""
personal = storage / "7" / "42" / "library"
sheet = _sheet(client)
seq = _mortar(sheet)["no"]
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": seq, "formula": "A*0.03"}]},
)
settings_before = (project / "project_settings.json").read_text(encoding="utf-8")
saved = client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]})
assert saved.status_code == 200, saved.text
assert saved.json()["code"] == PERSONAL_CODE # 같은 종류가 있어 그 코드로 덮어씀
files = list(personal.glob("*.json"))
assert len(files) == 1
item = json.loads(files[0].read_text(encoding="utf-8"))
mortar_row = next(row for row in item["rows"] if row["name"] == "모르터")
assert mortar_row["formula"] == "A*0.03" and mortar_row["source"] == "library"
assert "default_formula" not in mortar_row
assert item["library_tier"] == "personal" and item["pum_edition"] == "2026-01-01"
# 프로젝트는 그대로 — 박힌 양식 없음 · 고친 식 그대로.
assert library_module.project_templates(project) == {}
assert (project / "project_settings.json").read_text(encoding="utf-8") == settings_before
# 가져온 뒤 지워도 프로젝트는 그대로.
client.put(
f"{SHEETS}/library/import",
json={"type_id": "masonry_wet", "tier": "personal", "code": PERSONAL_CODE},
)
amount = _mortar(_sheet(client))["unit_amount"]
deleted = client.delete(f"{SHEETS}/library/personal", params={"type_id": "masonry_wet"})
assert deleted.json()["deleted"] == 1 and not list(personal.glob("*.json"))
assert _mortar(_sheet(client))["unit_amount"] == pytest.approx(amount)
def test_회사_없는_사람은_개인_단을_못_쓴다(client: TestClient) -> None:
client.app.dependency_overrides[verify_session] = lambda: {"company_id": None, "user_id": 1}
sheet = _sheet(client)
assert (
client.put(f"{SHEETS}/library/personal", json={"sheet_key": sheet["key"]}).status_code
== 403
)
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")