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_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 8a13f11d3..5fcdf94e7 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st "rows": rows, "driver_factor": driver_factor(master), "fx": fx_inputs(master), + "labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」 + } + + +def labor_editions(keys: list[str]) -> dict[str, str]: + """노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`.""" + files, master = m01.loaded() + groups = (files.get("인력.json") or {}).get("구분") or {} + out: dict[str, str] = {} + for key in demand(master, keys)["labor"]: + kind = str((_row(master, key) or {}).get("구분") or "") + if (groups.get(kind) or {}).get("판"): + out[kind] = str(groups[kind]["판"]) + return out + + +def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]: + """두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3).""" + new = json.loads(json.dumps(new, ensure_ascii=False)) + rows = (old or {}).get("rows") or {} + return { + "new": sum(key not in rows for key in new["rows"]), + "changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()), + "factor": (old or {}).get("driver_factor") != new["driver_factor"], + "fx": (old or {}).get("fx") != new["fx"], } @@ -206,15 +231,12 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None: if book is None: return "단가 아직 안 굳힘" # 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296) - fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False)) - rows = book.get("rows") or {} - new = sum(key not in rows for key in fresh["rows"]) - changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items()) + diff = book_diff(book, refresh_book(keys, book)) parts = [ - f"새 단가 줄 {new}" if new else "", - f"M01 에서 바뀐 단가 {changed}" if changed else "", - "운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "", - "환율 바뀜" if book.get("fx") != fresh["fx"] else "", + f"새 단가 줄 {diff['new']}" if diff["new"] else "", + f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "", + "운전사 계수 바뀜" if diff["factor"] else "", + "환율 바뀜" if diff["fx"] else "", ] return " · ".join(p for p in parts if p) or None diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py new file mode 100644 index 000000000..c29a75999 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -0,0 +1,72 @@ +"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께. + +미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본 + (연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 — + 재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날) +적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) · + 덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation.B09_Estimation_Calc_Labor import COPY +from M02_MasterTemplete import M02_Item_Copy as item_copy +from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots + + +def changed_copies(root: str | Path) -> list[dict[str, Any]]: + """연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`.""" + linked = {one["폴더"] for one in linked_snapshots(root).values()} + return [ + {"폴더": it["폴더"], "이름": it["이름"] or it["폴더"]} + for it in item_copy.list_items(root) + if it["폴더"] in linked and it["마스터바뀜"] + ] + + +def cover_cells(cover: dict[str, Any] | None) -> int: + """고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌.""" + return sum( + len(cell) for lines in (cover or {}).values() for cid, cell in lines.items() if cid != COPY + ) + + +def edition_text(editions: dict[str, str] | None) -> str: + return " · ".join(f"{kind} {day[:7]}" for kind, day in sorted((editions or {}).items())) + + +def preview( + root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None +) -> dict[str, Any]: + diff = prices.book_diff(book, prices.refresh_book(keys, book)) + before = (book or {}).get("labor_edition") + made = str((book or {}).get("made") or "")[:10] + return { + "단가줄": diff["new"] + diff["changed"], + "새줄": diff["new"], + "바뀐줄": diff["changed"], + "계수": diff["factor"], + "환율": diff["fx"], + "사본": changed_copies(root), + "고친칸": cover_cells(cover), + "노임판": { + "전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"), + "후": edition_text(prices.labor_editions(keys)), + }, + } + + +def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]: + """원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`.""" + done, stuck = [], [] + for one in changed_copies(root): + try: + item_copy.reapply(root, one["폴더"], who) + done.append(one["이름"]) + except (LookupError, ValueError, KeyError, OSError) as error: + stuck.append(f"{one['이름']} — {error}") + return {"다시": done, "막힘": stuck} diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index bf856eb6e..3f43b2ede 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -11,8 +11,13 @@ 원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 → 답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음 POST /api/projects/{id}/estimation/prices {user?} - [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · - 지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 + [업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) → + 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은 + 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 · + 답 notes 첫 줄 = 바뀐 수 + POST /api/projects/{id}/estimation/prices/preview {user?} + [업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · + 노임 판(쓰지 않음) POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks} 「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 · 저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2) @@ -24,6 +29,10 @@ 운반공 줄 = {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,7 +62,9 @@ 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_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices @@ -71,6 +82,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 @@ -264,16 +276,44 @@ async def estimation_reset(project_id: UUID) -> dict[str, Any]: @router.post("/{project_id}/estimation/prices") async def refresh_prices( - project_id: UUID, body: dict[str, Any] | None = Body(None) + project_id: UUID, + body: dict[str, Any] | None = Body(None), + session: dict[str, Any] = Depends(verify_session), ) -> dict[str, Any]: + """[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 · + 덮개 그대로.""" root = await _project_root(project_id) raw = (body or {}).get("user") draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함) + again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session)) keys = await _demand(root, draft) old = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.refresh_book, keys, old) await asyncio.to_thread(store.write_book, root, book) - return await payload(project_id, root, draft) + out = await payload(project_id, root, draft) + diff = prices.book_diff(old, book) + cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover")) + out["notes"] = [ + f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 " + f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로", + *(f"재적용 못 함 {why}" for why in again["막힘"]), + *(out.get("notes") or []), + ] + return out + + +@router.post("/{project_id}/estimation/prices/preview") +async def refresh_preview( + project_id: UUID, body: dict[str, Any] | None = Body(None) +) -> dict[str, Any]: + """[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판.""" + root = await _project_root(project_id) + raw = (body or {}).get("user") + draft = store.clean_user(raw) if isinstance(raw, dict) else None + keys = await _demand(root, draft) + book = await asyncio.to_thread(store.read_book, root) + user = draft if draft is not None else await asyncio.to_thread(store.read_user, root) + return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover")) class PreviewBody(BaseModel): @@ -385,6 +425,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_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 07ce77bb7..19d56c11f 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -4,15 +4,23 @@ * * [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄) * 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버 + * 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) → + * 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] = + * 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함 * 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 + * 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키 * ========================================================================== */ +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { openModal } from "@ui/ui_template_modal"; import { createBadge } from "@ui/ui_template_badge"; import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { dateTimeText } from "../common_util/common_util_time"; +import { readState } from "../A00_Common/b_page_state"; +import { currentProjectId } from "../A00_Common/b_project_switch"; import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; export interface CheckItem { @@ -59,7 +67,65 @@ export function refreshTip(payload: Row | null): string { const made = payload?.book?.made; const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘"; const stale = payload?.stale; - return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; + return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`; +} + +/** [업데이트] 미리 보기 글 줄(703-3) */ +export function previewLines(got: Row): string[] { + const copies: Row[] = got.사본 || []; + const edition = got.노임판 || {}; + return [ + `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, + `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, + `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, + ]; +} + +/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */ +function attachPreview(refresh: HTMLElement, payload: () => Row | null): void { + let pass = false; + refresh.addEventListener( + "click", + (event) => { + if (pass) return void (pass = false); + event.stopImmediatePropagation(); + const projectId = currentProjectId(); + const user = readState("b09-estimation", projectId) ?? payload()?.user ?? {}; + void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user }), + }) + .then(async (response) => { + const got = (await response.json()) as Row; + if (!response.ok) throw new Error(String(got?.detail ?? response.status)); + openModal({ + title: "업데이트 미리 보기", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, head) => { + const apply = createButton({ + label: "적용", + variant: "filled", + onClick: () => { + close(); + pass = true; + refresh.click(); + }, + }); + apply.dataset.b09RefreshApply = ""; + head.append(apply); + body.append(...previewLines(got).map((line) => createText(line))); + }, + }); + }) + .catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error")); + }, + true, + ); } export interface CheckPanel { @@ -72,6 +138,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi let payload: Row | null = null; let items: CheckItem[] = []; attachHoverInfo(refresh, () => refreshTip(payload)); + attachPreview(refresh, () => payload); const box = document.createElement("div"); box.className = "b09-check"; attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]"); 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_3_b09_refresh.py b/resources/tester/test_703_3_b09_refresh.py new file mode 100644 index 000000000..cd6bb5bc9 --- /dev/null +++ b/resources/tester/test_703_3_b09_refresh.py @@ -0,0 +1,79 @@ +"""703-3 B09 [업데이트] 한 곳 — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 재적용. + +- 미리 보기 수(`Refresh.preview`) = 적용 뒤 책 견줌(`Prices.book_diff`) 수 · 노임 판 전 → 후 · 고친 칸 수 +- 재적용 = 연결된 사본 중 원본이 바뀐 것만 · 하나가 막혀도 나머지 · 덮개(사용자 값) 안 건드림 +""" + +import json +import sys +from pathlib import Path + +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_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Refresh as refresh # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 + +KEYS = ["LB000001", "LB000033"] # 작업반장(건설업) · 석공 + + +def test_책_견줌_미리보기_수_적용_수_같음(tmp_path, monkeypatch): + monkeypatch.setattr(refresh, "changed_copies", lambda root: []) + new = prices.refresh_book(KEYS) + assert new["labor_edition"] == prices.labor_editions(KEYS) and new["labor_edition"] + old = json.loads(json.dumps(new)) + old["rows"]["LB000001"] = {**old["rows"]["LB000001"], "price": 1} # 옛 판 값 + del old["rows"]["LB000033"] # 새 줄 + old["labor_edition"] = {"건설업": "2025-09-01"} + cover = { + "S:a": {"x#0": {"qty": 1, "labor": 2}}, + "K:b": {"@copy": {"from": "S:a"}, "y#0": {"qty": 3}}, + } + got = refresh.preview(tmp_path, old, KEYS, cover) + after = prices.refresh_book(KEYS, old) + diff = prices.book_diff(old, after) + assert (got["새줄"], got["바뀐줄"]) == (diff["new"], diff["changed"]) == (1, 1) + assert got["단가줄"] == 2 and got["고친칸"] == 3 and got["사본"] == [] + assert got["노임판"]["전"] == "건설업 2025-09" + assert got["노임판"]["후"].startswith("건설업 2026") + old.pop("labor_edition") + assert refresh.preview(tmp_path, old, KEYS, None)["노임판"]["전"].startswith("굳힌 날 ") + assert prices.book_diff(after, prices.refresh_book(KEYS, after)) == { + "new": 0, + "changed": 0, + "factor": False, + "fx": False, + } + assert prices.book_stale(after, KEYS) is None + + +def test_재적용_원본_바뀐_사본만_막혀도_나머지_덮개_그대로(tmp_path, monkeypatch): + cover = {"S:a": {"x#0": {"qty": 0.3}}} + store.write_user(tmp_path, {"daega_cover": cover}) + monkeypatch.setattr( + refresh, + "linked_snapshots", + lambda root: {"p1": {"폴더": "a"}, "p2": {"폴더": "b"}, "p3": {"폴더": "c"}}, + ) + items = [ + {"폴더": "a", "이름": "가", "마스터바뀜": True}, + {"폴더": "b", "이름": "나", "마스터바뀜": False}, # 원본 그대로 = 안 함 + {"폴더": "c", "이름": "다", "마스터바뀜": True}, + {"폴더": "z", "이름": "끊김", "마스터바뀜": True}, # 연결 안 됨 = 안 함 + ] + monkeypatch.setattr(refresh.item_copy, "list_items", lambda root: items) + calls = [] + + def fake(root, folder, who=None): + calls.append(folder) + if folder == "c": + raise LookupError("원본 층 없음") + + monkeypatch.setattr(refresh.item_copy, "reapply", fake) + assert [c["이름"] for c in refresh.changed_copies(tmp_path)] == ["가", "다"] + got = refresh.reapply_changed(tmp_path) + assert calls == ["a", "c"] + assert got == {"다시": ["가"], "막힘": ["다 — 원본 층 없음"]} + assert store.read_user(tmp_path)["daega_cover"] == cover 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