diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index e900d37a8..538f200ff 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -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]: diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 3a7a18abc..3ebcdc119 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -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, diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 609b4be82..50651819c 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -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 = { labor: "노임 단가 없음", material: "자재 적용 단가 없음", @@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record, 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, diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py new file mode 100644 index 000000000..b45119661 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index d25845f50..d11c78572 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 6a8ec90e3..e228e2de4 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 9867464c2..c419f16f6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -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 = { @@ -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( (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, + ), ] : []), ); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts new file mode 100644 index 000000000..b0ec94253 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -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 { + 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 { + 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, bid: string, hit: (ln: Row) => boolean): boolean { + const seen = new Set(); + 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( + (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] + : []), + ]; +} diff --git a/resources/tester/test_703_b09_daega_copy.py b/resources/tester/test_703_b09_daega_copy.py new file mode 100644 index 000000000..b1f272386 --- /dev/null +++ b/resources/tester/test_703_b09_daega_copy.py @@ -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