Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user