feat(B09): 설계내역서 연결 고르기 서고 구조물조합 · 일위대가 사전조합 — 프로젝트 사본 들여와 새 호표 · 같은 것 재사용 · 저장 때 안 쓰는 사본 정리 (204-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2).
|
||||
|
||||
자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}`
|
||||
(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표):
|
||||
combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 ·
|
||||
도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값)
|
||||
precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} ·
|
||||
`단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌)
|
||||
사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) ·
|
||||
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
|
||||
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
|
||||
[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Store import folder as store_folder
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
PRICE_FILE,
|
||||
item_name,
|
||||
labor_doc_rows,
|
||||
price_bundle,
|
||||
read_json,
|
||||
)
|
||||
|
||||
LIBRARY = "library"
|
||||
KINDS = ("combo", "precombo")
|
||||
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`)
|
||||
NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$")
|
||||
|
||||
|
||||
def library_dir(project_root: str | Path) -> Path:
|
||||
return store_folder(project_root) / LIBRARY
|
||||
|
||||
|
||||
def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str:
|
||||
"""고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError."""
|
||||
name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}"
|
||||
if not NAME.match(name):
|
||||
raise ValueError(f"서고 항목 모양이 틀림 「{name}」")
|
||||
return name
|
||||
|
||||
|
||||
def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
ref = {"층": layer, "주인": owner, "키": key}
|
||||
doc = refs.read_ref(ref)
|
||||
if doc is None or doc.get("종류") != refs.TITLE["precombo"]:
|
||||
raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음")
|
||||
line = {"참조": ref, "수량": {"손": 1}}
|
||||
labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]})
|
||||
return {
|
||||
LABOR_FILE: labor,
|
||||
PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)),
|
||||
"_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""},
|
||||
}
|
||||
|
||||
|
||||
def import_copy(
|
||||
project_root: str | Path,
|
||||
kind: str,
|
||||
layer: str,
|
||||
owner: str | None,
|
||||
key: str,
|
||||
who: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) ·
|
||||
원본이 없으면 LookupError · 모양이 틀리면 ValueError."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
|
||||
name = folder_name(kind, layer, owner, key)
|
||||
target = library_dir(project_root) / name
|
||||
if isinstance(read_json(target / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
if kind == "combo":
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
files, origin = build_snapshot(item), origin_of(item)
|
||||
else:
|
||||
files = _precombo_files(layer, owner, key)
|
||||
origin = files.pop("_원본")
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True)
|
||||
for file, content in files.items():
|
||||
atomic_write_json(target / file, content)
|
||||
stamp = now_text()
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
"""다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례."""
|
||||
base = library_dir(project_root)
|
||||
out = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict):
|
||||
out.append((child, manifest))
|
||||
return out
|
||||
|
||||
|
||||
def used_folders(user: dict[str, Any]) -> set[str]:
|
||||
"""설계내역서 덮개 줄이 잇는 서고 사본 폴더."""
|
||||
rows = ((user or {}).get("bill") or {}).get("rows") or []
|
||||
return {
|
||||
str(ref["lib"])
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(ref := row.get("ref"), dict)
|
||||
and ref.get("tab") == "dg"
|
||||
and ref.get("lib")
|
||||
}
|
||||
|
||||
|
||||
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
|
||||
"""[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름."""
|
||||
keep = used_folders(user)
|
||||
base = library_dir(project_root)
|
||||
gone = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
if child.is_dir() and NAME.match(child.name) and child.name not in keep:
|
||||
shutil.rmtree(child)
|
||||
gone.append(child.name)
|
||||
return gone
|
||||
@@ -26,6 +26,7 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg
|
||||
from B09_Estimation import B09_Estimation_Sangeun_Text as st
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
@@ -282,6 +283,19 @@ class Builder:
|
||||
}
|
||||
)
|
||||
|
||||
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
|
||||
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
|
||||
if manifest.get("종류") != "precombo":
|
||||
return self.combo(folder)
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
ref = (manifest.get("원본") or {}).get("참조")
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None
|
||||
if not isinstance(doc, dict):
|
||||
return None
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
return self.precombo(whole, ref, doc, copies, 1)
|
||||
|
||||
|
||||
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
|
||||
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
|
||||
@@ -327,9 +341,11 @@ def _plain(value: Any) -> Any:
|
||||
|
||||
|
||||
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것.
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 ·
|
||||
설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}.
|
||||
|
||||
차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례.
|
||||
차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) —
|
||||
호표 번호 = 이 차례.
|
||||
"""
|
||||
base = structures_dir(project_root)
|
||||
build = Builder()
|
||||
@@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음
|
||||
if bid:
|
||||
owners[owner] = bid
|
||||
libs: dict[str, str] = {}
|
||||
for folder, manifest in library.copies(project_root):
|
||||
try:
|
||||
bid = build.library(folder, manifest)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
bid = None
|
||||
if bid:
|
||||
libs[folder.name] = bid
|
||||
order = {"조합": 0, "사전조합": 1, "품셈": 2}
|
||||
blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]])
|
||||
return _plain({"blocks": blocks, "owners": owners})
|
||||
out = {"blocks": blocks, "owners": owners}
|
||||
return _plain({**out, "libs": libs} if libs else out)
|
||||
|
||||
@@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]:
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음."""
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음."""
|
||||
from B09_Estimation.B09_Estimation_Bill_Library import copies
|
||||
|
||||
base = structures_dir(project_root)
|
||||
folders = [
|
||||
base / linked["폴더"]
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
out: list[str] = []
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items()):
|
||||
if "일위대가" not in linked.get("부분", []):
|
||||
continue
|
||||
for key in used_keys(base / linked["폴더"]):
|
||||
for folder in [*folders, *(f for f, _ in copies(project_root))]:
|
||||
for key in used_keys(folder):
|
||||
if key not in out:
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
|
||||
POST /api/projects/{id}/estimation/prices
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
|
||||
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
|
||||
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
|
||||
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
|
||||
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`)
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
@@ -20,11 +24,13 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
@@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
|
||||
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -139,7 +148,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
@router.put("/{project_id}/estimation")
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
user = await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
@@ -156,5 +166,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
kind: str
|
||||
layer: str
|
||||
owner: str | None = None
|
||||
key: str
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/library")
|
||||
async def import_library(
|
||||
project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
made = await asyncio.to_thread(
|
||||
library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session)
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
out = await payload(project_id, root)
|
||||
key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"])
|
||||
return {**made, "key": key, "payload": out}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
@@ -95,12 +95,14 @@ def _text(value: Any) -> str:
|
||||
|
||||
|
||||
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름)."""
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) ·
|
||||
dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움)."""
|
||||
if not isinstance(ref, dict):
|
||||
return None
|
||||
tab = ref.get("tab")
|
||||
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
||||
return {"tab": tab, "key": ref["key"]}
|
||||
lib = ref.get("lib") if tab == "dg" else None
|
||||
return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
|
||||
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
||||
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
||||
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Link.ts
|
||||
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
|
||||
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
|
||||
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
|
||||
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
|
||||
* 연결 풀기.
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
|
||||
* 층 읽기 권한 = 서버(`M02_Layer_Access`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
@@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
|
||||
);
|
||||
}
|
||||
|
||||
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
|
||||
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
|
||||
async function importLib(
|
||||
ctx: TabContext,
|
||||
apply: ApplyRef,
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
showToast("서고에서 들여오는 중…", "info");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pick),
|
||||
});
|
||||
const got = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok || !got) {
|
||||
const detail = got?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
|
||||
const { user: _user, ...rest } = got.payload as Row;
|
||||
Object.assign(ctx.payload, rest);
|
||||
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
|
||||
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
|
||||
showToast(
|
||||
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
|
||||
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
void openItemPicker({ current: null, canCopy: false }).then((got) => {
|
||||
if (got.action !== "apply") return;
|
||||
const { layer, owner, key } = got.item;
|
||||
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
|
||||
});
|
||||
}
|
||||
|
||||
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
|
||||
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
openLogicPicker(() => undefined, {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
libOnly: true,
|
||||
title: "일위대가 사전조합 고르기",
|
||||
pick: (ref) =>
|
||||
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
|
||||
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
@@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
|
||||
["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)],
|
||||
["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)],
|
||||
[
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
|
||||
|
||||
@@ -132,8 +132,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
|
||||
root = layer_root(item)
|
||||
row = row_of(read_json(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
@@ -153,6 +151,14 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
files[PRICE_FILE] = price_bundle(labor, root)
|
||||
return files
|
||||
|
||||
|
||||
def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
"""사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 ·
|
||||
개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2)."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
@@ -164,9 +170,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
]
|
||||
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
if mine := own.bundle(
|
||||
[c for c in materials if own.is_own(c)], root
|
||||
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
|
||||
files[PRICE_FILE][own.BUNDLE] = mine
|
||||
return files
|
||||
bundle[own.BUNDLE] = mine
|
||||
return bundle
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface LibraryPick {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
pick: (ref: LaborRef, name: string) => void;
|
||||
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
|
||||
libOnly?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||||
@@ -213,7 +216,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h3", { text: tc("Pick_Title") }),
|
||||
el("h3", { text: lib?.title ?? tc("Pick_Title") }),
|
||||
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
@@ -226,14 +229,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
document.body.append(backdrop);
|
||||
finder.search.focus();
|
||||
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
.then(([s, l]) => {
|
||||
subs.push(...s);
|
||||
all = l;
|
||||
drawSubs();
|
||||
find();
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
);
|
||||
if (!lib?.libOnly)
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
.then(([s, l]) => {
|
||||
subs.push(...s);
|
||||
all = l;
|
||||
drawSubs();
|
||||
find();
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,7 +311,13 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
|
||||
picks = [
|
||||
"일위대가 호표",
|
||||
"단가산출 호표",
|
||||
"서고 구조물조합(마스터 · 회사 · 개인)",
|
||||
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
] # 204-2
|
||||
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
|
||||
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
|
||||
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인)
|
||||
구조물조합 · 사전조합.
|
||||
|
||||
- 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) +
|
||||
줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표)
|
||||
- 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦
|
||||
- [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴
|
||||
- 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409)
|
||||
- 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결
|
||||
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402
|
||||
from config import config_system # noqa: E402
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
|
||||
from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402
|
||||
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡
|
||||
NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM
|
||||
NET_ITEM = "MT006697"
|
||||
MAT = "MT000008"
|
||||
COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄)
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch):
|
||||
system = copy_master_sample(tmp_path / "master")
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
lines = [
|
||||
{"로직": STONE, "수량": {"손": 2}},
|
||||
{"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}},
|
||||
]
|
||||
with store.at(system):
|
||||
key = refs.create(
|
||||
"precombo",
|
||||
{"이름": "서고 조합", "단위": "㎡", "담은로직": lines,
|
||||
"자재": [{"코드": MAT, "수량": {"손": 3}}]},
|
||||
)["이름"] # fmt: skip
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
|
||||
|
||||
def _block(root, folder):
|
||||
got = daega_inputs(root)
|
||||
bid = got["libs"][folder]
|
||||
return got, next(b for b in got["blocks"] if b["id"] == bid)
|
||||
|
||||
|
||||
def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world):
|
||||
root = world["root"]
|
||||
made = library.import_copy(root, "precombo", "system", None, world["key"])
|
||||
folder = made["폴더"]
|
||||
assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True}
|
||||
copy = library.library_dir(root) / folder
|
||||
assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"}
|
||||
got, block = _block(root, folder)
|
||||
assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡")
|
||||
kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]]
|
||||
assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)]
|
||||
assert got["owners"] == {} # 구조물 사본 없어도 호표
|
||||
# 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음)
|
||||
bundle = json.loads((copy / "단가.json").read_text("utf-8"))
|
||||
text = json.dumps(bundle, ensure_ascii=False)
|
||||
assert all(code in text for code in (STONE, NET, MAT, NET_ITEM))
|
||||
keys = linked_resource_keys(root)
|
||||
assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦
|
||||
# 다시 고름 = 새로 안 들여옴 · 같은 호표
|
||||
again = library.import_copy(root, "precombo", "system", None, world["key"])
|
||||
assert again == {"폴더": folder, "만듦": False}
|
||||
assert _block(root, folder)[1]["id"] == block["id"]
|
||||
# 원본을 지워도 굳힌 사본 그대로
|
||||
(world["system"] / "precombo" / f"{world['key']}.json").unlink()
|
||||
assert _block(root, folder)[1]["id"] == block["id"]
|
||||
|
||||
|
||||
def test_구조물조합_고름_사본_조합_호표(world):
|
||||
root = world["root"]
|
||||
made = library.import_copy(root, "combo", "system", None, COMBO)
|
||||
copy = library.library_dir(root) / made["폴더"]
|
||||
names = {p.name for p in copy.iterdir()}
|
||||
assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names
|
||||
manifest = json.loads((copy / "manifest.json").read_text("utf-8"))
|
||||
assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO
|
||||
assert manifest["원본"]["경로"] # 가져온 때 경로
|
||||
_, block = _block(root, made["폴더"])
|
||||
assert block["kind"] == "조합" and block["lines"]
|
||||
assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO}
|
||||
|
||||
|
||||
def test_없는_것_모양_틀림(world):
|
||||
root = world["root"]
|
||||
with pytest.raises(LookupError):
|
||||
library.import_copy(root, "precombo", "system", None, "SC-999999")
|
||||
with pytest.raises(LookupError):
|
||||
library.import_copy(root, "combo", "system", None, "SS999999")
|
||||
with pytest.raises(ValueError):
|
||||
library.import_copy(root, "precombo", "system", None, "../x")
|
||||
with pytest.raises(ValueError):
|
||||
library.import_copy(root, "other", "system", None, world["key"])
|
||||
assert library.copies(root) == []
|
||||
|
||||
|
||||
def test_저장_때_안_잇는_사본_지움_lib_지킴(world):
|
||||
root = world["root"]
|
||||
a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"]
|
||||
b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"]
|
||||
ref = {"tab": "dg", "key": "DG:x", "lib": a}
|
||||
user = store_b09.clean_user(
|
||||
{"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}}
|
||||
)
|
||||
assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴
|
||||
assert library.prune(root, user) == [b]
|
||||
assert [f.name for f, _ in library.copies(root)] == [a]
|
||||
assert library.prune(root, {}) == [a] and library.copies(root) == []
|
||||
|
||||
|
||||
def _call(monkeypatch, root, session, **body):
|
||||
async def fake_root(_):
|
||||
return root
|
||||
|
||||
async def fake_payload(_, at):
|
||||
return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}}
|
||||
|
||||
monkeypatch.setattr(router, "_project_root", fake_root)
|
||||
monkeypatch.setattr(router, "payload", fake_payload)
|
||||
req = router.LibraryBody(**body)
|
||||
return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session))
|
||||
|
||||
|
||||
def test_층_권한_길(world, monkeypatch):
|
||||
root = world["root"]
|
||||
mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"}
|
||||
got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"])
|
||||
assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}"
|
||||
assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]]
|
||||
assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[
|
||||
"가져온이"
|
||||
] == {"id": 1, "이름": "갑"}
|
||||
other = {"user_id": 2, "company_id": 8, "role": "USER"}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, other, kind="precombo", layer="company", owner="7",
|
||||
key=world["company"]) # fmt: skip
|
||||
assert error.value.status_code == 403
|
||||
lonely = {"user_id": 3, "company_id": None, "role": "USER"}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"])
|
||||
assert error.value.status_code == 409
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999")
|
||||
assert error.value.status_code == 404
|
||||
got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"])
|
||||
assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음
|
||||
|
||||
|
||||
# ── 화면 ──────────────────────────────────────────────────────────────
|
||||
LINK_JS = """
|
||||
const toasts = [];
|
||||
const sent = [];
|
||||
globalThis.showToast = (m, k) => toasts.push([k, m]);
|
||||
globalThis.API_BASE_URL = "/api";
|
||||
globalThis.CURRENT_PROJECT_ID_KEY = "pid";
|
||||
globalThis.localStorage = { getItem: () => "P1" };
|
||||
globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]);
|
||||
lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); };
|
||||
globalThis.openItemPicker = async () => ({ action: "apply",
|
||||
item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } });
|
||||
const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new",
|
||||
payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } },
|
||||
sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } };
|
||||
globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]);
|
||||
return { ok: true, status: 200, json: async () => answer }; };
|
||||
const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} };
|
||||
const applied = [];
|
||||
const apply = (ref, from) => applied.push([ref, from && from.name]);
|
||||
const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply);
|
||||
const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) };
|
||||
picks.find((x) => x[0].startsWith("서고 일위대가"))[1]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
picks.find((x) => x[0].startsWith("서고 구조물"))[1]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
Object.assign(out, { sent, applied, payload: ctx.payload, toasts });
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
|
||||
src = IMPORTS.sub(
|
||||
"", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8")
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = json.loads(result.stdout)
|
||||
assert out["picks"] == [
|
||||
"일위대가 호표",
|
||||
"단가산출 호표",
|
||||
"서고 구조물조합(마스터 · 회사 · 개인)",
|
||||
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
]
|
||||
assert out["more"] == ["연결 풀기"]
|
||||
pre, (url, body) = out["sent"][0], out["sent"][1]
|
||||
assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋
|
||||
assert url == "/api/projects/P1/estimation/library"
|
||||
assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"}
|
||||
assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None,
|
||||
"key": "SS000011"} # fmt: skip
|
||||
ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"}
|
||||
assert out["applied"][0] == [ref, "회사 조합"]
|
||||
# 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음
|
||||
assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}}
|
||||
assert out["toasts"][-1][0] == "success"
|
||||
Reference in New Issue
Block a user