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:
2026-10-10 14:39:12 +09:00
co-authored by Claude Opus 5.5
parent 694ca5fee3
commit 49b3e79c2a
10 changed files with 558 additions and 31 deletions
@@ -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
+28 -3
View File
@@ -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)
+11 -5
View File
@@ -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
+38 -2
View File
@@ -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)
+4 -2
View File
@@ -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.이름)),
+11 -5
View File
@@ -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"),
);
}
+7 -1
View File
@@ -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"