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:
@@ -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"))
|
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:
|
def _typed(value: Any, default: Any) -> Any:
|
||||||
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
|
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
|
||||||
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
||||||
@@ -150,6 +158,7 @@ def replace_with_templates(
|
|||||||
section_modes: dict[float, str] | None,
|
section_modes: dict[float, str] | None,
|
||||||
rubble_base_thickness_m: float | None,
|
rubble_base_thickness_m: float | None,
|
||||||
formula_overrides: dict[str, Any] | None = None,
|
formula_overrides: dict[str, Any] | None = None,
|
||||||
|
templates: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
|
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
|
||||||
|
|
||||||
@@ -166,7 +175,7 @@ def replace_with_templates(
|
|||||||
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
|
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
|
||||||
targets: list[tuple[Any, dict[str, Any]]] = []
|
targets: list[tuple[Any, dict[str, Any]]] = []
|
||||||
for quantity, item in zip(quantities, inputs):
|
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:
|
if template is None or not quantity.components:
|
||||||
continue
|
continue
|
||||||
options = item.get("options") or {}
|
options = item.get("options") or {}
|
||||||
@@ -196,7 +205,7 @@ def replace_with_templates(
|
|||||||
if solved is None:
|
if solved is None:
|
||||||
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
||||||
continue
|
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"]}
|
by_seq = {row["seq"]: row for row in body["rows"]}
|
||||||
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
|
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
|
||||||
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
|
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
|
||||||
@@ -236,7 +245,11 @@ def replace_with_templates(
|
|||||||
quantity.library_item = str(template.get("name") or quantity.type_id)
|
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장).
|
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `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 {}
|
all_overrides = (settings or {}).get(OVERRIDES_KEY) or {}
|
||||||
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
||||||
for sheet in payload.get("sheets") or []:
|
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:
|
if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS:
|
||||||
for row in sheet.get("rows") or []:
|
for row in sheet.get("rows") or []:
|
||||||
row.setdefault("formula", "")
|
row.setdefault("formula", "")
|
||||||
@@ -269,7 +282,7 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
|
|||||||
return
|
return
|
||||||
solved = evaluate_sheets([body for _sheet, body in targets])
|
solved = evaluate_sheets([body for _sheet, body in targets])
|
||||||
for index, (sheet, body) in enumerate(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:
|
if solved is None:
|
||||||
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
||||||
continue
|
continue
|
||||||
@@ -281,7 +294,13 @@ def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = N
|
|||||||
for row in sheet["rows"]
|
for row in sheet["rows"]
|
||||||
if row["unit_amount"] is None and not row["skipped"]
|
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
|
sheet["formula_sheet"] = body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1511,6 +1511,7 @@ def build_table(
|
|||||||
rubble_base_thickness_m: float | None = None,
|
rubble_base_thickness_m: float | None = None,
|
||||||
use_templates: bool = True,
|
use_templates: bool = True,
|
||||||
structure_formulas: dict[str, Any] | None = None,
|
structure_formulas: dict[str, Any] | None = None,
|
||||||
|
structure_templates: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
|
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
|
||||||
|
|
||||||
@@ -1519,6 +1520,8 @@ def build_table(
|
|||||||
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
|
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
|
||||||
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
|
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
|
||||||
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
|
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
|
||||||
|
⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`).
|
||||||
|
없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김.
|
||||||
"""
|
"""
|
||||||
observed = load_observed_table()
|
observed = load_observed_table()
|
||||||
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
|
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
|
||||||
@@ -1545,6 +1548,7 @@ def build_table(
|
|||||||
section_modes,
|
section_modes,
|
||||||
rubble_base_thickness_m,
|
rubble_base_thickness_m,
|
||||||
structure_formulas,
|
structure_formulas,
|
||||||
|
structure_templates,
|
||||||
)
|
)
|
||||||
violations = verify_no_mix_components(quantities)
|
violations = verify_no_mix_components(quantities)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
구현할 때 이 라우터를 확장한다.
|
구현할 때 이 라우터를 확장한다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -20,7 +21,25 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
|||||||
|
|
||||||
@router.post("/{project_id}/quantity/confirm")
|
@router.post("/{project_id}/quantity/confirm")
|
||||||
async def confirm_quantity(project_id: UUID) -> JSONResponse:
|
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()
|
pool = get_db_pool()
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
try:
|
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_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_Engine_MaterialSummary import build_table as build_material_table
|
||||||
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
|
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 build_table as build_unit_table
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
||||||
ground_types_from_designs,
|
ground_types_from_designs,
|
||||||
@@ -157,6 +158,8 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
|
|||||||
settings.get("rubble_base_thickness_m"),
|
settings.get("rubble_base_thickness_m"),
|
||||||
# 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤).
|
# 구조물도에서 고친 식 — 안 넘기면 원단위·자재총괄이 구조물도와 갈림(PLAN 3장 ⑤).
|
||||||
structure_formulas=settings.get("structure_formula_overrides"),
|
structure_formulas=settings.get("structure_formula_overrides"),
|
||||||
|
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
|
||||||
|
structure_templates=project_templates(project_root),
|
||||||
)
|
)
|
||||||
material_table = build_material_table(
|
material_table = build_material_table(
|
||||||
unit_table,
|
unit_table,
|
||||||
@@ -210,6 +213,7 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
|
|||||||
await _ground_types(project_id),
|
await _ground_types(project_id),
|
||||||
settings.get("rubble_base_thickness_m"),
|
settings.get("rubble_base_thickness_m"),
|
||||||
structure_formulas=settings.get("structure_formula_overrides"),
|
structure_formulas=settings.get("structure_formula_overrides"),
|
||||||
|
structure_templates=project_templates(project_root),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
|
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),
|
await _ground_types(project_id),
|
||||||
settings.get("rubble_base_thickness_m"),
|
settings.get("rubble_base_thickness_m"),
|
||||||
structure_formulas=settings.get("structure_formula_overrides"),
|
structure_formulas=settings.get("structure_formula_overrides"),
|
||||||
|
structure_templates=project_templates(project_root),
|
||||||
)
|
)
|
||||||
material_table = build_material_table(
|
material_table = build_material_table(
|
||||||
unit_table,
|
unit_table,
|
||||||
|
|||||||
@@ -14,14 +14,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
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 common_util.common_util_storage import resolve_stored_project_path
|
||||||
from config.config_db import run_with_connection
|
from config.config_db import run_with_connection
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ def project_structure_sheets(
|
|||||||
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
|
⚠ 늦게 부른다(함수 안 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_StructureSheet import build_standard_sheets
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
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)
|
structures, names, skipped = _collect_structures(project_root)
|
||||||
settings = quantity_settings(project_root)
|
settings = quantity_settings(project_root)
|
||||||
|
templates = project_templates(project_root)
|
||||||
unit_table = build_unit_table(
|
unit_table = build_unit_table(
|
||||||
structures,
|
structures,
|
||||||
names,
|
names,
|
||||||
@@ -66,10 +69,11 @@ def project_structure_sheets(
|
|||||||
ground_types,
|
ground_types,
|
||||||
settings.get("rubble_base_thickness_m"),
|
settings.get("rubble_base_thickness_m"),
|
||||||
structure_formulas=settings.get("structure_formula_overrides"),
|
structure_formulas=settings.get("structure_formula_overrides"),
|
||||||
|
structure_templates=templates,
|
||||||
)
|
)
|
||||||
payload = build_standard_sheets(unit_table, section_modes)
|
payload = build_standard_sheets(unit_table, section_modes)
|
||||||
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
|
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
|
||||||
apply_templates(payload, settings)
|
apply_templates(payload, settings, templates)
|
||||||
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
|
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
|
||||||
payload["skipped_structures"] = skipped
|
payload["skipped_structures"] = skipped
|
||||||
return payload
|
return payload
|
||||||
@@ -141,10 +145,11 @@ async def put_structure_sheet_formulas(
|
|||||||
⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장).
|
⚠ 개인 라이브러리에 저장하지 않음 — 그것은 [내 라이브러리에 저장] 단추 몫(명세 13장 Ⓒ, 4장).
|
||||||
⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임.
|
⚠ 식이 풀리지 않아도 **막지 않고 저장**하되 줄마다 오류를 돌려줌 — 어디가 틀렸는지 보임.
|
||||||
"""
|
"""
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
|
||||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
|
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import (
|
||||||
OVERRIDES_KEY,
|
OVERRIDES_KEY,
|
||||||
load_template,
|
|
||||||
save_sheet_overrides,
|
save_sheet_overrides,
|
||||||
|
template_of,
|
||||||
)
|
)
|
||||||
from common_util.common_util_project_settings import quantity_settings, save_section
|
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()
|
return _not_found()
|
||||||
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
|
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)
|
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:
|
if picked is None or template is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
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")
|
@router.get("/{project_id}/quantity/structure-sheets")
|
||||||
async def get_structure_sheets(project_id: UUID) -> JSONResponse:
|
async def get_structure_sheets(project_id: UUID) -> JSONResponse:
|
||||||
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
|
"""구조물도(표준도) **장 목록 + 원단위 수량표**.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type StandardSheetSpec,
|
type StandardSheetSpec,
|
||||||
type StandardSpecResult,
|
type StandardSpecResult,
|
||||||
} from "./B08_Quantity_UI_StructureSheet_Spec";
|
} from "./B08_Quantity_UI_StructureSheet_Spec";
|
||||||
|
import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library";
|
||||||
|
|
||||||
export interface StructureSheetRow {
|
export interface StructureSheetRow {
|
||||||
no: number;
|
no: number;
|
||||||
@@ -104,7 +105,13 @@ export interface StructureSheet extends StandardSheetSpec {
|
|||||||
notes: string[];
|
notes: string[];
|
||||||
unpriced_rows: 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, 고친 식 얹힘). */
|
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
|
||||||
formula_sheet?: FormulaSheet;
|
formula_sheet?: FormulaSheet;
|
||||||
}
|
}
|
||||||
@@ -394,6 +401,11 @@ function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
|
|||||||
return scroller;
|
return scroller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 사용자가 고친 식 줄 수 — 장 머리 「그 뒤 고쳤나」와 가져오기 전 물음에 씀. */
|
||||||
|
function editedRows(sheet: StructureSheet): number {
|
||||||
|
return sheet.rows.filter((row) => row.source === "user").length;
|
||||||
|
}
|
||||||
|
|
||||||
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */
|
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표(양식 장은 식 칸) · 막힌 사유 · 개소 목록. */
|
||||||
function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement {
|
function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLElement {
|
||||||
const main = el("div", "b08-sheet__main");
|
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.title} — ${sheet.member_count}개소${total} · ` +
|
||||||
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
// 양식 있음/없음을 머리에서 바로 가림 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
||||||
(sheet.library_item ? `양식 「${sheet.library_item.name}」` : "양식 없음(지금 전개)"),
|
(sheet.library_item
|
||||||
|
? libraryLabel(sheet.library_item, editedRows(sheet))
|
||||||
|
: "양식 없음(지금 전개)"),
|
||||||
),
|
),
|
||||||
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
|
||||||
el("span", "b08-grid__caption", sheet.unit_label),
|
el("span", "b08-grid__caption", sheet.unit_label),
|
||||||
@@ -541,6 +555,27 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
|
|||||||
initialNotes,
|
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
|
const editor = sheet.formula_sheet
|
||||||
? formulaTable(
|
? formulaTable(
|
||||||
sheet,
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
"code": "AX-ST-56e81a2c",
|
||||||
"library_tier": "program",
|
"library_tier": "program",
|
||||||
"item_kind": "form",
|
"item_kind": "form",
|
||||||
"type_id": "masonry_wet",
|
"type_id": "masonry_wet",
|
||||||
@@ -31,7 +32,11 @@
|
|||||||
"default": 0
|
"default": 0
|
||||||
},
|
},
|
||||||
"기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" },
|
"기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" },
|
||||||
"BLIND": { "label": "버림 콘크리트(넣음·안 넣음)", "option": "blinding_concrete", "default": "넣음" },
|
"BLIND": {
|
||||||
|
"label": "버림 콘크리트(넣음·안 넣음)",
|
||||||
|
"option": "blinding_concrete",
|
||||||
|
"default": "넣음"
|
||||||
|
},
|
||||||
"SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" },
|
"SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" },
|
||||||
"MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" },
|
"MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" },
|
||||||
"HOLE_DIA": { "label": "물구멍 지름(㎜)", "option": "weep_hole_diameter_mm", "default": 50 },
|
"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")
|
||||||
Reference in New Issue
Block a user