feat(B09 설계내역서 펼침): 우클릭 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나 (PLAN 703-2)
- 복사 = 덮개 K:… 의 @copy {from · name · spec} + 원본 덮개째(B09 사용자 값 · 브레인 정함) — 셈 짝 Calc_Labor py·ts 가 원본 블록을 본뜬 새 호표(번호 끝 · 기본값 = 원본) · 이 줄 단가연결을 새 호표로 바꿀지 물음 · 복사본 지우기(잇는 줄 있으면 막음)
- 원본 없음 = 짝 잃음 알림 · 저장 다듬기 @copy 글만 · 서고 사본 원본 = prune 에서 지킴
- 내 자료집 = POST …/estimation/daega/personal → 개인 층 사전조합(B09_Estimation_Daega_Personal) — 넣을 값 · 고른 자재 · 줄 수량 덮개(손) 담음 · 품 · 단가 덮개 = 안 담은 수 알림 · 같은 이름 409 → 덮어쓰기 · 새 이름
- 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 · 펼침
- 새 파일 B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts · 시험 test_703_b09_daega_copy(거울 · 임시 storage 길)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -341,12 +341,18 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
|
||||
|
||||
def used_folders(user: dict[str, Any]) -> set[str]:
|
||||
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더."""
|
||||
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 +
|
||||
일위대가 복사본(703-2 `@copy`)의 원본 사본."""
|
||||
copies = [
|
||||
str(lines["@copy"].get("from") or "")
|
||||
for lines in (user.get("daega_cover") or {}).values()
|
||||
if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict)
|
||||
]
|
||||
return {
|
||||
str(ref["lib"])
|
||||
for row in ref_rows(user)
|
||||
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
|
||||
}
|
||||
} | {src[2:] for src in copies if src.startswith("L:")}
|
||||
|
||||
|
||||
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
|
||||
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
|
||||
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
|
||||
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
|
||||
PARTS = ("labor", "material", "expense")
|
||||
COVER_KEYS = ("qty", *PARTS)
|
||||
TAB = "dg"
|
||||
COPY = "@copy"
|
||||
WHY = {
|
||||
"labor": "노임 단가 없음",
|
||||
"material": "자재 적용 단가 없음",
|
||||
@@ -142,6 +145,21 @@ def _block(
|
||||
}
|
||||
|
||||
|
||||
def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨)."""
|
||||
out = list(blocks)
|
||||
for key, lines in cover.items():
|
||||
meta = (lines or {}).get(COPY)
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
src = next((b for b in out if b.get("cover") == meta.get("from")), None)
|
||||
if src is not None:
|
||||
spec = meta["spec"] if "spec" in meta else src["spec"]
|
||||
name = meta.get("name") or src["name"]
|
||||
out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec})
|
||||
return out
|
||||
|
||||
|
||||
def compute_labor(
|
||||
daega: dict[str, Any] | None,
|
||||
sheets: dict[str, Any],
|
||||
@@ -161,6 +179,7 @@ def compute_labor(
|
||||
"sangeun": sangeun or {},
|
||||
}
|
||||
cover = cover or {}
|
||||
blocks = with_copies(blocks, cover)
|
||||
by_id = {b["id"]: b for b in blocks}
|
||||
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
|
||||
done: dict[str, dict[str, Any]] = {}
|
||||
@@ -211,7 +230,7 @@ def compute_labor(
|
||||
{"cover": key, "cid": cid}
|
||||
for key, lines in cover.items()
|
||||
for cid in lines
|
||||
if cid not in cids.get(key, ())
|
||||
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
|
||||
]
|
||||
return {
|
||||
"daega_blocks": out_blocks,
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
|
||||
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
|
||||
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
|
||||
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
const COVER_KEYS = ["qty", ...PARTS] as const;
|
||||
const TAB = "dg";
|
||||
export const COPY = "@copy";
|
||||
const WHY: Record<string, string> = {
|
||||
labor: "노임 단가 없음",
|
||||
material: "자재 적용 단가 없음",
|
||||
@@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
|
||||
};
|
||||
}
|
||||
|
||||
/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */
|
||||
function withCopies(blocks: Row[], covers: Row): Row[] {
|
||||
const out = [...blocks];
|
||||
for (const [key, lines] of Object.entries(covers)) {
|
||||
const meta: Row | undefined = (lines || {})[COPY];
|
||||
if (!meta || typeof meta !== "object") continue;
|
||||
const src = out.find((b) => b.cover === meta.from);
|
||||
if (!src) continue;
|
||||
const spec = "spec" in meta ? meta.spec : src.spec;
|
||||
out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
|
||||
export function computeLabor(
|
||||
daega: Row | null | undefined,
|
||||
@@ -153,8 +170,8 @@ export function computeLabor(
|
||||
sangeun: Row = {},
|
||||
covers: Row = {},
|
||||
): Row {
|
||||
const blocks: Row[] = (daega || {}).blocks || [];
|
||||
covers = covers || {};
|
||||
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
|
||||
const index = (rows: Row[] | undefined): Row =>
|
||||
Object.fromEntries((rows || []).map((r) => [r.key, r]));
|
||||
const books: Row = {
|
||||
@@ -207,7 +224,8 @@ export function computeLabor(
|
||||
const orphans: Row[] = [];
|
||||
for (const [key, lines] of Object.entries(covers))
|
||||
for (const cid of Object.keys(lines || {}))
|
||||
if (!cids.get(key)?.has(cid)) orphans.push({ cover: key, cid });
|
||||
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
|
||||
orphans.push({ cover: key, cid });
|
||||
return {
|
||||
daega_blocks: outBlocks,
|
||||
daega: listing,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC).
|
||||
|
||||
담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) —
|
||||
맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림)
|
||||
안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) ·
|
||||
단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김)
|
||||
원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) ·
|
||||
사전조합 `C:`/`L:` = 굳힌 참조사본 문서 · 품셈 `P:` = 로직 한 줄 ·
|
||||
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
|
||||
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
|
||||
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
MANIFEST_NAME,
|
||||
PICKS_FILE,
|
||||
VALUES_FILE,
|
||||
structures_dir,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
KIND = "precombo"
|
||||
PRICE_KEYS = ("labor", "material", "expense")
|
||||
|
||||
|
||||
def _combo(folder: Path) -> tuple[list[dict], list[dict]]:
|
||||
"""구조물조합 사본 → (담은로직 · 자재) — 넣을 값 · 고른 자재 얹고 수량 = 셈 수량(손)."""
|
||||
labor = read_json(folder / LABOR_FILE) or {}
|
||||
values = read_json(folder / VALUES_FILE) or {}
|
||||
picks = read_json(folder / PICKS_FILE) or {}
|
||||
got = item_prices.calc(
|
||||
labor, read_json(folder / PRICE_FILE) or {}, values, picks, read_json(folder / AREA_FILE)
|
||||
)
|
||||
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
|
||||
lines = [
|
||||
{**one, "수량": {"손": done.get("수량")}}
|
||||
for one, done in zip(doc.get("담은로직") or [], got.get("줄") or [])
|
||||
]
|
||||
mats = [
|
||||
{**one, "수량": {"손": done.get("수량")}}
|
||||
for one, done in zip(doc.get("자재") or [], got.get("자재") or [])
|
||||
]
|
||||
return lines, mats
|
||||
|
||||
|
||||
def _frozen(root: Path, rid: str) -> dict[str, Any] | None:
|
||||
"""굳힌 사전조합 문서 — 구조물 사본 · 서고 사본의 `참조사본` 에서 찾음."""
|
||||
folders = [p for p in sorted(structures_dir(root).glob("*")) if p.is_dir()]
|
||||
folders += [p for p, _m in library.copies(root)]
|
||||
for folder in folders:
|
||||
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(rid)
|
||||
if isinstance(doc, dict):
|
||||
return doc
|
||||
return None
|
||||
|
||||
|
||||
def _precombo(doc: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
||||
lines = [
|
||||
{**one, "수량": {"손": (one.get("수량") or {}).get("손", 1)}}
|
||||
for one in doc.get("담은로직") or []
|
||||
if isinstance(one, dict)
|
||||
]
|
||||
return lines, [m for m in doc.get("자재") or [] if isinstance(m, dict)]
|
||||
|
||||
|
||||
def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], list[dict]]:
|
||||
"""블록 원천(`base` = 복사본이면 원본 cover) → (담은로직 · 자재) · 못 찾으면 LookupError."""
|
||||
head, _, rest = base.partition(":")
|
||||
if block["kind"] == "품셈":
|
||||
origin = block.get("origin") or {}
|
||||
one = {"로직": origin.get("로직"), "입력값": origin.get("입력값") or {}}
|
||||
one.update({k: origin[k] for k in ("켜쌓기율", "고름") if origin.get(k) is not None})
|
||||
return [{**one, "수량": {"손": 1}}], []
|
||||
if head == "S":
|
||||
return _combo(structures_dir(root) / rest)
|
||||
if head == "L":
|
||||
folder = library.library_dir(root) / rest
|
||||
manifest = read_json(folder / MANIFEST_NAME) or {}
|
||||
if manifest.get("종류") != "precombo":
|
||||
return _combo(folder)
|
||||
ref = (manifest.get("원본") or {}).get("참조")
|
||||
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref))
|
||||
if isinstance(doc, dict):
|
||||
return _precombo(doc)
|
||||
if head == "C" and (doc := _frozen(root, rest)) is not None:
|
||||
return _precombo(doc)
|
||||
raise LookupError(f"일위대가 원천을 못 찾음 「{base}」")
|
||||
|
||||
|
||||
def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = None) -> dict:
|
||||
"""블록 cover → `{문서, 담음, 안담음}` — 문서 = 사전조합 꼴(키 · 층 쓰기 전)."""
|
||||
raw = daega_inputs(root).get("blocks") or []
|
||||
blocks = {b["cover"]: b for b in with_copies(raw, cover)}
|
||||
block = blocks.get(cover_key)
|
||||
if block is None:
|
||||
raise LookupError(f"없는 일위대가 「{cover_key}」")
|
||||
base = cover_key
|
||||
while base.startswith("K:"): # 복사본 → 원본 cover
|
||||
base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "")
|
||||
lines, mats = source(root, blocks.get(base, block), base)
|
||||
mine = cover.get(cover_key) or {}
|
||||
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
|
||||
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
|
||||
skip = {"품": 0, "단가": 0}
|
||||
top = block["lines"] if block["kind"] != "품셈" else []
|
||||
for line, one in zip(top, [*lines, *mats]):
|
||||
cell = mine.get(line.get("cid")) or {}
|
||||
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
|
||||
if "qty" not in cell:
|
||||
continue
|
||||
hand, each = (one.get("수량") or {}).get("손"), line.get("qty")
|
||||
if not isinstance(hand, (int, float)) or not each:
|
||||
skip["품"] += 1
|
||||
continue
|
||||
one["수량"] = {"손": round(hand * cell["qty"] / each, 9)}
|
||||
kept["수량"] += 1
|
||||
# 맨 위 밖 덮개 = 품셈 블록 줄 · 부른 블록 안 줄(품) · 단가
|
||||
by_id = {b["id"]: b for b in blocks.values()}
|
||||
todo = [] if top else [block["id"]]
|
||||
todo += [ln.get("key") for ln in top if ln.get("kind") == "daega"]
|
||||
seen: set[str] = set()
|
||||
while todo:
|
||||
sub = by_id.get(todo.pop())
|
||||
if sub is None or sub["id"] in seen:
|
||||
continue
|
||||
seen.add(sub["id"])
|
||||
for cell in (cover.get(sub["cover"]) or {}).values():
|
||||
skip["품"] += "qty" in cell
|
||||
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
|
||||
todo += [ln.get("key") for ln in sub["lines"] if ln.get("kind") == "daega"]
|
||||
doc = {
|
||||
"이름": (name or block.get("name") or "").strip(),
|
||||
"단위": block.get("unit") or "",
|
||||
"담은로직": lines,
|
||||
"자재": mats,
|
||||
}
|
||||
return {"문서": doc, "담음": kept, "안담음": skip}
|
||||
|
||||
|
||||
def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
|
||||
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
|
||||
same = [
|
||||
path.stem
|
||||
for path in sorted((store.root() / KIND).glob("SC-*.json"))
|
||||
if (read_json(path) or {}).get("이름") == doc["이름"]
|
||||
]
|
||||
if overwrite:
|
||||
if overwrite not in same:
|
||||
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
|
||||
old = store.read(KIND, overwrite)
|
||||
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
|
||||
if same:
|
||||
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
|
||||
return refs.create(KIND, doc)
|
||||
@@ -24,6 +24,9 @@
|
||||
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
|
||||
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
|
||||
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
|
||||
POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?}
|
||||
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
|
||||
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
|
||||
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
@@ -53,6 +56,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
|
||||
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Daega_Personal as personal
|
||||
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
|
||||
@@ -71,6 +75,7 @@ from common_util.common_util_project_settings import reset_section
|
||||
from common_util.common_util_workflow_state import mark_stage
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
|
||||
|
||||
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
|
||||
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
|
||||
@@ -381,6 +386,38 @@ async def library_inputs(
|
||||
return {"lines": lines}
|
||||
|
||||
|
||||
class PersonalBody(BaseModel):
|
||||
cover: str = Field(min_length=1, max_length=200)
|
||||
name: str | None = Field(None, max_length=200)
|
||||
overwrite: str | None = Field(None, max_length=20)
|
||||
user: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/daega/personal")
|
||||
async def save_personal(
|
||||
project_id: UUID, body: PersonalBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
raw = (
|
||||
body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root)
|
||||
)
|
||||
cover = store.clean_user(raw).get("daega_cover") or {}
|
||||
try:
|
||||
made = await asyncio.to_thread(personal.build, root, body.cover, cover, body.name)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
if not made["문서"]["이름"]:
|
||||
raise HTTPException(status_code=400, detail="이름이 빔")
|
||||
folder = await asyncio.to_thread(_root, session, "personal", None, True)
|
||||
got = await asyncio.to_thread(_call, folder, personal.write, made["문서"], body.overwrite)
|
||||
return {
|
||||
"키": got["이름"],
|
||||
"이름": made["문서"]["이름"],
|
||||
"담음": made["담음"],
|
||||
"안담음": made["안담음"],
|
||||
}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
||||
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
|
||||
|
||||
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
||||
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
||||
@@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def clean_cover(raw: Any) -> dict[str, Any]:
|
||||
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림."""
|
||||
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 ·
|
||||
복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림)."""
|
||||
out: dict[str, Any] = {}
|
||||
for block, lines in (raw if isinstance(raw, dict) else {}).items():
|
||||
kept = {}
|
||||
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
|
||||
cell = cell if isinstance(cell, dict) else {}
|
||||
if cid == "@copy":
|
||||
if _text(cell.get("from")):
|
||||
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
|
||||
continue
|
||||
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
|
||||
if got:
|
||||
kept[str(cid)[:200]] = got
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
|
||||
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
||||
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
||||
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
@@ -16,9 +17,11 @@ import { createCard } from "@ui/ui_template_card";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
|
||||
|
||||
/** 고칠 칸 → 덮개 칸 */
|
||||
const FIELD: Record<string, string> = {
|
||||
@@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
|
||||
const covered = (at: FoldLine | undefined, field: string): boolean =>
|
||||
!!at && (at.line.covered || []).includes(field);
|
||||
|
||||
function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement {
|
||||
function foldCard(
|
||||
ctx: TabContext,
|
||||
raw: DataRow[],
|
||||
row: Row,
|
||||
block: Row,
|
||||
close: () => void,
|
||||
go: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const blocks = new Map<string, Row>(
|
||||
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
|
||||
);
|
||||
@@ -168,9 +178,11 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
|
||||
);
|
||||
attachRowMenu(table.root, (i) => {
|
||||
const at = lines[i];
|
||||
return at && (at.line.covered || []).length
|
||||
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
|
||||
: [];
|
||||
const undo: MapContextMenuItem[] =
|
||||
at && (at.line.covered || []).length
|
||||
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
|
||||
: [];
|
||||
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
|
||||
});
|
||||
const actions = [
|
||||
...(edited.length
|
||||
@@ -204,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
|
||||
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
|
||||
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
||||
const host = document.createElement("div");
|
||||
/** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */
|
||||
const go = (key: string): void => {
|
||||
const i = raw.findIndex((r) => String(r.key) === key);
|
||||
table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" });
|
||||
open = key;
|
||||
draw();
|
||||
};
|
||||
const draw = (): void => {
|
||||
const row = raw.find((r) => r.key === open) as Row | undefined;
|
||||
const ref: Row | undefined = row?.ref;
|
||||
@@ -214,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme
|
||||
host.replaceChildren(
|
||||
...(row && block
|
||||
? [
|
||||
foldCard(ctx, row, block, () => {
|
||||
open = null;
|
||||
draw();
|
||||
}),
|
||||
foldCard(
|
||||
ctx,
|
||||
raw,
|
||||
row,
|
||||
block,
|
||||
() => {
|
||||
open = null;
|
||||
draw();
|
||||
},
|
||||
go,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
|
||||
* B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나.
|
||||
*
|
||||
* 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 ·
|
||||
* 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움)
|
||||
* 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) ·
|
||||
* 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림
|
||||
* 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { askFields } from "@ui/ui_template_item_picker_ask";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { COPY } from "./B09_Estimation_Calc_Labor";
|
||||
import type { TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */
|
||||
const GOODS = ["labor", "material", "machine"];
|
||||
/** 부른 대가 따라가는 깊이 */
|
||||
const DEPTH = 6;
|
||||
|
||||
const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
|
||||
const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`;
|
||||
|
||||
/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */
|
||||
async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise<void> {
|
||||
const got = await askFields({
|
||||
title: "복사해 새 일위대가",
|
||||
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
|
||||
fields: [
|
||||
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
|
||||
{ key: "spec", label: "규격", value: block.spec || "" },
|
||||
],
|
||||
okLabel: "만들기",
|
||||
closeLabel: "닫기",
|
||||
emptyText: "{label} 이 빔",
|
||||
});
|
||||
if (!got) return;
|
||||
const key = newKey();
|
||||
const all = covers(ctx);
|
||||
all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째
|
||||
const relink = await askConfirm({
|
||||
title: "단가연결",
|
||||
message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`,
|
||||
okLabel: "바꿈",
|
||||
cancelLabel: "그대로",
|
||||
});
|
||||
const next = { ...ctx, user: { ...ctx.user, daega_cover: all } };
|
||||
const i = raw.findIndex((r) => r.key === row.key);
|
||||
if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` });
|
||||
else ctx.setUser(next.user);
|
||||
showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success");
|
||||
}
|
||||
|
||||
/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */
|
||||
function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void {
|
||||
const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id);
|
||||
if (used.length) {
|
||||
showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error");
|
||||
return;
|
||||
}
|
||||
const all = covers(ctx);
|
||||
delete all[block.cover];
|
||||
ctx.setUser({ ...ctx.user, daega_cover: all });
|
||||
}
|
||||
|
||||
async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
|
||||
return { ok: response.ok, status: response.status, got };
|
||||
}
|
||||
|
||||
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
|
||||
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
|
||||
let name = await askText({
|
||||
title: "내 자료집에 저장",
|
||||
message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지",
|
||||
value: block.name || "",
|
||||
okLabel: "저장",
|
||||
});
|
||||
let overwrite: string | null = null;
|
||||
while (name !== null && name.trim()) {
|
||||
const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user };
|
||||
const { ok, status, got } = await postPersonal(body);
|
||||
const same = got.detail?.같은이름;
|
||||
if (ok) {
|
||||
const skip: Row = got.안담음 || {};
|
||||
const left = (skip.품 || 0) + (skip.단가 || 0);
|
||||
const tail = left
|
||||
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
|
||||
: "";
|
||||
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
|
||||
return;
|
||||
}
|
||||
if (status !== 409 || !same) {
|
||||
const detail = got.detail;
|
||||
showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error");
|
||||
return;
|
||||
}
|
||||
const over = await askConfirm({
|
||||
title: "같은 이름",
|
||||
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
|
||||
okLabel: "덮어쓰기",
|
||||
cancelLabel: "새 이름",
|
||||
});
|
||||
if (over) overwrite = same;
|
||||
else {
|
||||
overwrite = null;
|
||||
name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */
|
||||
function holds(blocks: Map<string, Row>, bid: string, hit: (ln: Row) => boolean): boolean {
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string, depth: number): boolean => {
|
||||
const b = blocks.get(id);
|
||||
if (!b || seen.has(id) || depth > DEPTH) return false;
|
||||
seen.add(id);
|
||||
return (b.lines || []).some(
|
||||
(ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)),
|
||||
);
|
||||
};
|
||||
return walk(bid, 0);
|
||||
}
|
||||
|
||||
/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */
|
||||
function whereUsed(
|
||||
ctx: TabContext,
|
||||
raw: DataRow[],
|
||||
block: Row,
|
||||
line: Row,
|
||||
go: (key: string) => void,
|
||||
): void {
|
||||
const blocks = new Map<string, Row>(
|
||||
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]),
|
||||
);
|
||||
const goods = GOODS.includes(line.kind);
|
||||
const id = line.kind === "daega" ? String(line.key) : String(block.id);
|
||||
const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`;
|
||||
const hit = goods
|
||||
? (ln: Row) => ln.kind === line.kind && ln.key === line.key
|
||||
: (ln: Row) => ln.kind === "daega" && ln.key === id;
|
||||
const used = raw.filter((r) => {
|
||||
const ref = r.ref as Row | undefined;
|
||||
if (ref?.tab !== "dg") return false;
|
||||
return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit);
|
||||
});
|
||||
openModal({
|
||||
title: `어디 쓰였나 — 「${label}」`,
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close) => {
|
||||
body.append(
|
||||
createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"),
|
||||
...used.map((r) =>
|
||||
createListRow({
|
||||
label: String(r.name || ""),
|
||||
sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
onClick: () => {
|
||||
close();
|
||||
go(String(r.key));
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */
|
||||
export function foldMenu(
|
||||
ctx: TabContext,
|
||||
raw: DataRow[],
|
||||
row: Row,
|
||||
block: Row,
|
||||
line: Row | undefined,
|
||||
go: (key: string) => void,
|
||||
): MapContextMenuItem[] {
|
||||
return [
|
||||
["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)],
|
||||
["내 자료집에 저장", () => void savePersonal(ctx, block)],
|
||||
["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)],
|
||||
...(String(block.cover).startsWith("K:")
|
||||
? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"""703-2 B09 펼친 일위대가 우클릭 — 복사해 새 일위대가 · 내 자료집에 저장.
|
||||
|
||||
- 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이
|
||||
원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 ·
|
||||
저장 다듬기 · 서고 사본 지킴(prune)
|
||||
- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 ·
|
||||
같은 이름 409 → 덮어쓰기 · 새 이름
|
||||
"""
|
||||
|
||||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
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_Daega_Personal as personal # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
|
||||
from config import config_system # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as m02 # noqa: E402
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
b145 = _load("test_145_2_b09_labor_calc")
|
||||
b702 = _load("test_702_b09_daega_cover")
|
||||
|
||||
#: DG9 찰붙임 복사 — 석공 품 0.1 → 0.2(복사본만) · 원본 DG9 덮개 0.12 는 원본에만
|
||||
COPY = {
|
||||
"T:DG9": {"labor:L00005#0": {"qty": 0.12}},
|
||||
"K:aa": {
|
||||
"@copy": {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"},
|
||||
"labor:L00005#0": {"qty": 0.2},
|
||||
},
|
||||
"K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}},
|
||||
"K:cc": {"@copy": {"from": "T:없음"}, "x#0": {"qty": 1}},
|
||||
}
|
||||
|
||||
|
||||
def _block(out, bid):
|
||||
return next(b for b in out["daega_blocks"] if b["id"] == bid)
|
||||
|
||||
|
||||
def test_복사본_새_호표_끝_번호_덮개_제것():
|
||||
daega = b702._covered_excel()
|
||||
out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)
|
||||
src, mine = _block(out, "DG9"), _block(out, "DG:K:aa")
|
||||
n = len(daega["blocks"])
|
||||
assert [mine["no"], _block(out, "DG:K:bb")["no"]] == [n + 1, n + 2] # 번호 새로(끝)
|
||||
assert (mine["name"], mine["spec"], mine["cover"]) == ("찰붙임 복사", "규격 B", "K:aa")
|
||||
assert src["lines"][0]["qty"] == 0.12 and mine["lines"][0]["qty"] == 0.2
|
||||
assert mine["lines"][0]["base"]["qty"] == 0.1 # 품셈 값 = 원본 기본값
|
||||
assert [ln["amount"] for ln in mine["lines"][1:]] == [ln["amount"] for ln in src["lines"][1:]]
|
||||
twice = _block(out, "DG:K:bb")
|
||||
assert twice["spec"] == "규격 B" and twice["lines"][0]["qty"] == 0.1 # 덮개 안 물려받음
|
||||
assert any(r["key"] == "DG:K:aa" for r in out["daega"]) # 목록표 = 내역 단가연결 자리
|
||||
assert out["daega_orphans"] == [
|
||||
{"cover": "K:cc", "cid": "@copy"},
|
||||
{"cover": "K:cc", "cid": "x#0"},
|
||||
]
|
||||
|
||||
|
||||
def test_거울_파이썬_TS_복사본():
|
||||
daega = b702._covered_excel()
|
||||
py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)))
|
||||
ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COPY)
|
||||
assert b145._same(py, ts["out"])
|
||||
|
||||
|
||||
def test_저장_다듬기_복사본_표지():
|
||||
raw = {"daega_cover": {**COPY, "K:dd": {"@copy": {"name": "원본 없음"}}}}
|
||||
got = store.clean_user(raw)["daega_cover"]
|
||||
assert got["K:aa"]["@copy"] == {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"}
|
||||
assert got["K:bb"] == {"@copy": {"from": "K:aa", "name": "복사의 복사"}}
|
||||
assert "K:dd" not in got
|
||||
|
||||
|
||||
def test_서고_사본_원본_지킴():
|
||||
user = {"daega_cover": {"K:aa": {"@copy": {"from": "L:combo@system@-@SS1"}}}}
|
||||
assert library.used_folders(user) == {"combo@system@-@SS1"}
|
||||
|
||||
|
||||
# ── 내 자료집(개인 층 사전조합 · 임시 storage) ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch) -> dict[str, Any]:
|
||||
(tmp_path / "storage").mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
root = b145._project(tmp_path / "proj", [(100, "SS000901")])
|
||||
b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE))
|
||||
folder = layers.personal_dir(7, 42)
|
||||
folder.mkdir(parents=True)
|
||||
items_store.ensure(folder)
|
||||
got = daega_inputs(root)
|
||||
block = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"])
|
||||
return {"root": root, "folder": folder, "block": block}
|
||||
|
||||
|
||||
def test_자료집_문서_덮개_손_안담음_수(world):
|
||||
block = world["block"]
|
||||
each = block["lines"][1]["qty"] # GF000119 줄 수량(산근 = 손 그대로 0.5)
|
||||
cover = {
|
||||
block["cover"]: {
|
||||
"sangeun:SG:GF000119#0": {"qty": 0.8, "labor": 1000}, # 줄 수량 = 담음 · 단가 = 안 담음
|
||||
}
|
||||
}
|
||||
made = personal.build(world["root"], block["cover"], cover, "내 돌붙임")
|
||||
doc = made["문서"]
|
||||
assert each == 0.5 and doc["이름"] == "내 돌붙임"
|
||||
hands = [ln["수량"]["손"] for ln in doc["담은로직"]]
|
||||
assert hands == [2, 0.8, 1] # 덮개 줄만 바뀜
|
||||
assert doc["담은로직"][0]["입력값"] == b145.STONE # 넣을 값 얹음
|
||||
assert made["담음"] == {"수량": 1, "넣을값": 1, "고른자재": 0}
|
||||
assert made["안담음"] == {"품": 0, "단가": 1}
|
||||
# 복사본 = 원본까지 따라감 · 이름 기본 = 복사본 이름 · 덮개 = 복사본 것
|
||||
cover2 = {"K:aa": {"@copy": {"from": block["cover"], "name": "복사"}}}
|
||||
cover2["K:aa"]["sangeun:SG:GF000219#0"] = {"qty": 3}
|
||||
made2 = personal.build(world["root"], "K:aa", cover2)
|
||||
assert made2["문서"]["이름"] == "복사"
|
||||
assert [ln["수량"]["손"] for ln in made2["문서"]["담은로직"]] == [3, 0.5, 1]
|
||||
with pytest.raises(LookupError):
|
||||
personal.build(world["root"], "S:없음", {})
|
||||
|
||||
|
||||
def test_자료집_같은_이름_물음(world):
|
||||
block = world["block"]
|
||||
doc = personal.build(world["root"], block["cover"], {}, "내 돌붙임")["문서"]
|
||||
with m02.at(world["folder"]):
|
||||
first = personal.write(doc)
|
||||
with pytest.raises(m02.StoreError) as e:
|
||||
personal.write(doc)
|
||||
assert e.value.status == 409 and e.value.detail["같은이름"] == first["이름"]
|
||||
changed = copy.deepcopy(doc)
|
||||
changed["담은로직"][1]["수량"] = {"손": 9}
|
||||
again = personal.write(changed, first["이름"]) # 덮어쓰기
|
||||
assert again["이름"] == first["이름"]
|
||||
other = personal.write({**doc, "이름": "내 돌붙임 2"}) # 새 이름
|
||||
assert other["이름"] != first["이름"]
|
||||
saved = json.loads(
|
||||
(world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9}
|
||||
assert saved["담은로직"][0]["입력값"] == b145.STONE
|
||||
assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2
|
||||
|
||||
|
||||
def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch):
|
||||
"""서버 길 — 화면 캐시 덮개째 · 개인 층(임시 storage) · 같은 이름 409 {같은이름} → 덮어쓰기."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B09_Estimation import B09_Estimation_Router as b09
|
||||
from common_util.common_util_auth import verify_session
|
||||
|
||||
async def root_of(_pid):
|
||||
return world["root"]
|
||||
|
||||
monkeypatch.setattr(b09, "_project_root", root_of)
|
||||
app = FastAPI()
|
||||
app.include_router(b09.router)
|
||||
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
|
||||
app.dependency_overrides[verify_session] = lambda: session
|
||||
client = TestClient(app)
|
||||
block = world["block"]
|
||||
url = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/daega/personal"
|
||||
user = {"daega_cover": {block["cover"]: {"sangeun:SG:GF000219#0": {"qty": 4}}}}
|
||||
body = {"cover": block["cover"], "name": "길 시험", "user": user}
|
||||
first = client.post(url, json=body)
|
||||
assert first.status_code == 200, first.text
|
||||
assert first.json()["담음"]["수량"] == 1
|
||||
again = client.post(url, json=body)
|
||||
assert again.status_code == 409 and again.json()["detail"]["같은이름"] == first.json()["키"]
|
||||
over = client.post(url, json={**body, "overwrite": first.json()["키"]})
|
||||
assert over.status_code == 200 and over.json()["키"] == first.json()["키"]
|
||||
saved = world["folder"] / "precombo" / f"{first.json()['키']}.json"
|
||||
assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4}
|
||||
assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404
|
||||
Reference in New Issue
Block a user