diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index dccc5f357..725ff48fb 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -119,6 +119,21 @@ function comboSection( return section; } const source = sourceOf(request.projectId, copy.폴더); + // 702-4 B09 원가에서 고친 일위대가 칸(덮개) — 카드 금액 = 사본 셈(고치기 전) · 셈 답 `B09고침` 수 + const covered = note(""); + covered.hidden = true; + const labor = source.labor; + if (labor) { + const calc = labor.calc; + labor.calc = (...args) => + calc(...args).then((got) => { + const n = Number((got as { B09고침?: number }).B09고침) || 0; + covered.textContent = `B09 원가에서 고친 일위대가 칸 ${n} — 이 카드 금액은 고치기 전 사본 값 · 원가 금액은 B09`; + covered.hidden = !n; + return got; + }); + } + section.append(covered); if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); const load = (): void => void cards.load({ id: copy.폴더, label: name }); // 카드 안 [저장]은 없음(자료 자리에 write 없음) — [수정]만 사본 편집 화면으로(163-3) diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 2dbb0035d..3a7a18abc 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -16,6 +16,9 @@ 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. 빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps). 빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김. +고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} — + 있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) · + 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. """ from __future__ import annotations @@ -25,6 +28,7 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total PARTS = ("labor", "material", "expense") +COVER_KEYS = ("qty", *PARTS) TAB = "dg" WHY = { "labor": "노임 단가 없음", @@ -69,24 +73,35 @@ def _base(spec: str, lines: list[dict[str, Any]]) -> float | None: return total([ln["amounts"]["labor"] for ln in lines if ln["kind"] == "labor"]) +def _cover(line: dict[str, Any], own: dict[str, Any], qty: Any, prices: dict) -> tuple: + """덮개 한 줄 — (수량, 단가, 덧칸) · 덧칸 = `base`(품셈 값) · `covered`(고친 칸 차례).""" + mine = own.get(line.get("cid")) or {} + keys = [k for k in COVER_KEYS if k in mine] + if not keys: + return qty, prices, {} + extra = {"base": {"qty": qty, **prices}, "covered": keys} + qty = num(mine["qty"]) if "qty" in mine else qty + return qty, {p: num(mine[p]) if p in mine else v for p, v in prices.items()}, extra + + def _block( block: dict[str, Any], books: dict[str, Any], sums: dict[str, dict], gaps: list, + own: dict[str, Any], ) -> dict[str, Any]: lines = [] for line in block["lines"]: if line["kind"] == "pct": continue - qty = num(line.get("qty")) - prices = _prices(line, books, sums) + qty, prices, extra = _cover(line, own, num(line.get("qty")), _prices(line, books, sums)) amounts = { part: None if p is None or qty is None else rounddown(p * qty, 1) for part, p in prices.items() } ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음 - lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts}) + lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts, **extra}) if qty is None or None in prices.values(): why = line.get("why") or ( "수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음") @@ -96,13 +111,15 @@ def _block( if line["kind"] != "pct": continue base = _base(line.get("base") or "lines:labor", lines) - pct = num(line.get("qty")) + mine = own.get(line.get("cid")) or {} + pct = num(mine["qty"]) if "qty" in mine else num(line.get("qty")) + extra = {"base": {"qty": num(line.get("qty"))}, "covered": ["qty"]} if "qty" in mine else {} prices = dict.fromkeys(PARTS, 0.0) amounts = dict.fromkeys(PARTS, 0.0) part = line.get("part") or "expense" prices[part] = base amounts[part] = None if base is None or pct is None else rounddown(base * pct / 100, 1) - lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts}) + lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts, **extra}) if amounts[part] is None: gaps.append( gap(TAB, block["id"], f"{block['name']} · {line.get('name')}", "% 밑수 없음") @@ -114,7 +131,7 @@ def _block( parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS} blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None] return { - **{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite")}, + **{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite", "cover")}, **({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선 "key": block["id"], "lines": lines, @@ -129,6 +146,7 @@ def compute_labor( daega: dict[str, Any] | None, sheets: dict[str, Any], sangeun: dict[str, Any] | None = None, + cover: dict[str, Any] | None = None, ) -> dict[str, Any]: """대가 탭 둘 — `daega_blocks`(일위대가표) · `daega`(목록표) · `daega_owners` + 바탕 gaps 이어 붙임. @@ -142,6 +160,7 @@ def compute_labor( "machine": {r["key"]: r for r in sheets.get("machine") or []}, "sangeun": sangeun or {}, } + cover = cover or {} 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]] = {} @@ -153,7 +172,7 @@ def compute_labor( for line in by_id[bid]["lines"]: if line["kind"] == "daega": run(line.get("key"), (*trail, bid)) - done[bid] = _block(by_id[bid], books, done, gaps) + done[bid] = _block(by_id[bid], books, done, gaps, cover.get(by_id[bid].get("cover")) or {}) for b in blocks: run(b["id"], ()) @@ -185,10 +204,20 @@ def compute_labor( } for b in out_blocks ] + cids: dict[str, set] = {} + for b in blocks: + cids.setdefault(b.get("cover"), set()).update(ln.get("cid") for ln in b["lines"]) + orphans = [ + {"cover": key, "cid": cid} + for key, lines in cover.items() + for cid in lines + if cid not in cids.get(key, ()) + ] return { "daega_blocks": out_blocks, "daega": listing, "daega_owners": dict(daega.get("owners") or {}), + "daega_orphans": orphans, "gaps": [*(sheets.get("gaps") or []), *gaps], } diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 312e27582..609b4be82 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -13,6 +13,8 @@ * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) * 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps) * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 + * 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} — + * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) * ========================================================================== */ import { @@ -27,6 +29,7 @@ import { } from "./B09_Estimation_Calc_Base"; const PARTS = ["labor", "material", "expense"] as const; +const COVER_KEYS = ["qty", ...PARTS] as const; const TAB = "dg"; const WHY: Record = { labor: "노임 단가 없음", @@ -70,19 +73,29 @@ function pctBase(spec: string, lines: Row[]): Num { return total(lines.filter((ln) => ln.kind === "labor").map((ln) => ln.amounts.labor)); } -function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row { +/** 덮개 한 줄 — [수량, 단가, 덧칸] · 덧칸 = base(품셈 값) · covered(고친 칸 차례) */ +function cover(line: Row, own: Row, qty0: Num, prices0: Parts): [Num, Parts, Row] { + const mine: Row = own[line.cid] || {}; + const keys = COVER_KEYS.filter((k) => k in mine); + if (!keys.length) return [qty0, prices0, {}]; + const extra = { base: { qty: qty0, ...prices0 }, covered: keys }; + const prices = { ...prices0 }; + for (const p of PARTS) if (p in mine) prices[p] = num(mine[p]); + return ["qty" in mine ? num(mine.qty) : qty0, prices, extra]; +} + +function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: Row): Row { const lines: Row[] = []; for (const line of b.lines) { if (line.kind === "pct") continue; - const qty = num(line.qty); - const prices = linePrices(line, books, sums); + const [qty, prices, extra] = cover(line, own, num(line.qty), linePrices(line, books, sums)); const amounts = blanks(); for (const part of PARTS) { const p = prices[part]; amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1); } const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음 - lines.push({ ...line, ...ref, qty, prices, amounts }); + lines.push({ ...line, ...ref, qty, prices, amounts, ...extra }); if (qty === null || PARTS.some((part) => prices[part] === null)) { const why = line.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음")); gaps.push(gap(TAB, b.id, `${b.name} · ${line.name || ""}`, why)); @@ -91,13 +104,15 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row for (const line of b.lines) { if (line.kind !== "pct") continue; const base = pctBase(line.base || "lines:labor", lines); - const pct = num(line.qty); + const mine: Row = own[line.cid] || {}; + const pct = "qty" in mine ? num(mine.qty) : num(line.qty); + const extra = "qty" in mine ? { base: { qty: num(line.qty) }, covered: ["qty"] } : {}; const prices = zeros(); const amounts = zeros(); const part = (line.part || "expense") as (typeof PARTS)[number]; prices[part] = base; amounts[part] = base === null || pct === null ? null : rounddown((base * pct) / 100, 1); - lines.push({ ...line, qty: pct, prices, amounts }); + lines.push({ ...line, qty: pct, prices, amounts, ...extra }); if (amounts[part] === null) gaps.push(gap(TAB, b.id, `${b.name} · ${line.name}`, "% 밑수 없음")); } @@ -122,6 +137,7 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row ...(b.spec_raw ? { spec_raw: b.spec_raw } : {}), // 157-4 풍선(파이썬 짝) unit: b.unit, cite: b.cite, + cover: b.cover ?? null, lines, ...parts, sum: PARTS.reduce((acc, p) => acc + (parts[p] as number), 0), @@ -131,8 +147,14 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row } /** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */ -export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun: Row = {}): Row { +export function computeLabor( + daega: Row | null | undefined, + sheets: Row, + sangeun: Row = {}, + covers: Row = {}, +): Row { const blocks: Row[] = (daega || {}).blocks || []; + covers = covers || {}; const index = (rows: Row[] | undefined): Row => Object.fromEntries((rows || []).map((r) => [r.key, r])); const books: Row = { @@ -148,7 +170,7 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun const run = (bid: string, trail: string[]): void => { if (bid in done || trail.includes(bid) || !(bid in byId)) return; for (const line of byId[bid].lines) if (line.kind === "daega") run(line.key, [...trail, bid]); - done[bid] = block(byId[bid], books, done, gaps); + done[bid] = block(byId[bid], books, done, gaps, covers[byId[bid].cover] || {}); }; for (const b of blocks) run(b.id, []); const outBlocks = blocks.map((b) => { @@ -176,10 +198,21 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun blank: b.blank, blank_names: b.blank_names, })); + const cids = new Map>(); + for (const b of blocks) { + const got = cids.get(b.cover) ?? new Set(); + for (const ln of b.lines) got.add(ln.cid); + cids.set(b.cover, got); + } + 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 }); return { daega_blocks: outBlocks, daega: listing, daega_owners: { ...((daega || {}).owners || {}) }, + daega_orphans: orphans, gaps: [...(sheets.gaps || []), ...gaps], }; } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 578f7dc74..c12fbdc2b 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -12,6 +12,9 @@ (155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음) 같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · +덮개 열쇠(702-2 · 내용 해시 밖 · 수량 · 단가가 바뀌어도 그대로) = 블록 `cover` + 줄 `cid` — + cover = 조합 `S:사본 폴더` · 서고 `L:폴더` · 사전조합 `C:참조` · 품셈 `P:로직 + 입력 해시` + cid = 줄 정체(비목 : 노임 · 자재 · 중기 코드 · 산근 · 부른 대가 로직 · 참조) + `#n번째` 줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 1 단위 몫 · 산근 단가는 이미 1 단위). 단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄. @@ -78,12 +81,35 @@ class Builder: self.blocks: dict[str, dict[str, Any]] = {} self.gaps: list[dict[str, str]] = [] - def put(self, block: dict[str, Any]) -> str: + def put(self, block: dict[str, Any], cover: str | None = None) -> str: bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}" if bid not in self.blocks: - self.blocks[bid] = {"id": bid, **block} + mark = {"품셈": "P", "사전조합": "C"}.get(block["kind"], "S") + if cover is None: + origin = block["origin"] + cover = f"C:{origin['참조']}" if mark == "C" else f"{mark}:{_digest(origin)}" + lines = [{**ln, "cid": cid} for ln, cid in zip(block["lines"], self._cids(block))] + self.blocks[bid] = {"id": bid, "cover": cover, **block, "lines": lines} return bid + def _cids(self, block: dict[str, Any]) -> list[str]: + """줄 정체(702-2) — 같은 정체 n번째 = `#n` · 재적용으로 줄이 늘거나 바뀌어도 맞는 줄에.""" + seen: dict[str, int] = {} + out = [] + for line in block["lines"]: + kind, key = line["kind"], str(line.get("key") or "") + if kind == "daega" and key in self.blocks: + origin = self.blocks[key]["origin"] + key = str(origin.get("로직") or origin.get("참조") or key) + elif kind == "sangeun": + key = key.rsplit(":", 1)[0] # SG:로직:입력 해시 → 로직만 + elif kind == "pct": + key = str(line.get("base") or "") + head = f"{kind}:{key or line.get('name') or ''}" + seen[head] = seen.get(head, -1) + 1 + out.append(f"{head}#{seen[head]}") + return out + # ── 품셈(로직 하나) ───────────────────────────────────────────────── def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: code = str(one.get("로직") or "") @@ -245,7 +271,7 @@ class Builder: ) # ── 조합(구조물 사본) ─────────────────────────────────────────────── - def combo(self, folder: Path) -> str | None: + def combo(self, folder: Path, cover: str | None = None) -> str | None: labor = read_json(folder / LABOR_FILE) if not isinstance(labor, dict): return None @@ -280,13 +306,14 @@ class Builder: "cite": "", "origin": {k: origin.get(k) for k in ("layer", "owner", "key")}, "lines": lines, - } + }, + cover or f"S:{folder.name}", ) def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" if manifest.get("종류") != "precombo": - return self.combo(folder) + return self.combo(folder, f"L:{folder.name}") labor = read_json(folder / LABOR_FILE) ref = (manifest.get("원본") or {}).get("참조") copies = (labor or {}).get("참조사본") or {} @@ -340,6 +367,28 @@ def _plain(value: Any) -> Any: return value +def cover_count(project_root: str | Path, folder: str) -> int: + """702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록에 붙은 B09 고친 칸 수(카드 = 사본 셈).""" + from B09_Estimation.B09_Estimation_Store import read_user + + cover = read_user(project_root).get("daega_cover") or {} + if not cover: + return 0 + got = daega_inputs( + project_root + ) # ponytail: 프로젝트 한 벌 셈 — 카드 열 때만 · 느리면 사본 하나로 + by_id = {b["id"]: b for b in got["blocks"]} + owners = {linked["폴더"]: o for o, linked in linked_snapshots(project_root).items()} + todo, seen = [got["owners"].get(owners.get(folder, ""))], set() + while todo: + block = by_id.get(todo.pop()) + if block is None or block["id"] in seen: + continue + seen.add(block["id"]) + todo += [ln.get("key") for ln in block["lines"] if ln["kind"] == "daega"] + return sum(len(cover.get(by_id[bid]["cover"]) or {}) for bid in seen) + + def daega_inputs(project_root: str | Path) -> dict[str, Any]: """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 · 설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}. diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 841f6caa3..d25845f50 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -106,7 +106,9 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> base = compute_base(inputs, user) sg = sangeun.build(root, base, user) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직) by_key = {row["key"]: row for row in sg["sg_list"]} - labor = compute_labor(inputs.get("daega"), {**base, "gaps": []}, by_key) + labor = compute_labor( + inputs.get("daega"), {**base, "gaps": []}, by_key, user.get("daega_cover") + ) books = {"daega": labor["daega"], "sg_list": sg["sg_list"], "sg_hand": sg["sg_hand"]} sheets = compute_estimation(inputs, user, lambda owner: daega_price(labor, owner), books) sheets.update(sg) @@ -187,6 +189,10 @@ async def payload( inputs["daega"] = await asyncio.to_thread(daega_inputs, root) library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록 sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) + if lost := len(sheets.get("daega_orphans") or []): # 702-2 데이터 점검 알림 + notes.append( + f"짝 잃은 고친 칸 {lost} — 일위대가 줄이 바뀌어 붙을 줄 없음 · 고친 값은 남겨 둠" + ) if schedule_draft: schedule_draft.attach(inputs, sheets, user) return { diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index cca16ec2c..6a8ec90e3 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -10,6 +10,8 @@ (184 설계내역서 덮개 — 꼴은 `B09_Estimation_Calc_Bill` 머리 · 옛 꼴 {"qty", "rows"}(166-2 · 182)도 그대로 다듬음) — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) + "daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}} + (702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`) quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · 읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 @@ -29,6 +31,7 @@ from urllib.parse import unquote from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS +from B09_Estimation.B09_Estimation_Calc_Labor import COVER_KEYS try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이) from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule @@ -223,6 +226,21 @@ def clean_bill(raw: Any) -> dict[str, Any]: return out +def clean_cover(raw: Any) -> dict[str, Any]: + """702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림.""" + 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 {} + 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 + if kept: + out[str(block)[:200]] = kept + return out + + def clean_user(raw: Any) -> dict[str, Any]: """사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만.""" raw = raw if isinstance(raw, dict) else {} @@ -255,6 +273,8 @@ def clean_user(raw: Any) -> dict[str, Any]: estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} bill = clean_bill(raw.get("bill")) out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + if cover := clean_cover(raw.get("daega_cover")): + out["daega_cover"] = cover if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))): out["schedule"] = schedule return out diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 4eeb511d8..e03f541e6 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -100,6 +100,7 @@ export function computeSheets(payload: Row, user: Row): Row { payload.inputs.daega, { ...computeBase(payload.inputs, user), gaps: [] }, sg, + user.daega_cover, // 702-2 고친 칸 덮개 ); const books = { daega: labor.daega, sg_list: kept.sg_list, sg_hand: kept.sg_hand }; const base = computeEstimation( @@ -264,7 +265,8 @@ export function renderB09Estimation(root: HTMLElement): void { // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; - panel.replaceChildren(table.root, ...(foot ? [foot] : [])); + const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래 + panel.replaceChildren(table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 7ae93caf2..fac5b923f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -24,6 +24,7 @@ import { openLinkMenu, type BillTable, } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold"; const TEXT_KEYS = ["name", "spec", "unit"]; /** 폴더 줄 수식 칸 — 자손 합(셈) */ @@ -126,6 +127,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { }, menu: (i) => billMenu(ctx, raw, i), drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before), + fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개 onCell: (i, key) => { if (key === "connect") openLinkMenu(ctx, raw, i); }, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index 99243bdb4..ca6bf6e32 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -32,6 +32,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[]; /** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */ drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null; + /** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */ + fold?: (table: HTMLElement) => HTMLElement; }; const NEW_NAME = "새 줄"; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts new file mode 100644 index 000000000..4bf765137 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -0,0 +1,235 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Bill_Fold.ts + * B09 설계내역서 줄 펼침(PLAN 702-1 · 702-2) — 내역 줄 누름 = 표 아래에 그 줄의 일위대가 구성. + * + * 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값 + * 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지) + * 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover` + * {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 · + * [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4) + * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 + * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +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 { 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"; + +/** 고칠 칸 → 덮개 칸 */ +const FIELD: Record = { + qty: "qty", + labor_price: "labor", + material_price: "material", + expense_price: "expense", +}; +const LABEL: Record = { qty: "품", labor: "노", material: "재", expense: "경" }; +/** 부른 대가 펼침 깊이 */ +const DEPTH = 3; + +/** 펼친 줄 — 덮개 자리(블록 cover · 줄 cid) + 셈 줄 */ +interface FoldLine { + cover: string | null; + line: Row; + level: number; +} + +/** 지금 펼친 내역 줄 키(다시 그려도 그대로) */ +let open: string | null = null; + +const plain = (v: unknown): string => + typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸"; + +/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */ +function baseText(line: Row): string { + const covered: string[] = line.covered || []; + if (!covered.length) return "같음"; + return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); +} + +function linesOf(block: Row, blocks: Map, level: number, trail: string[]): FoldLine[] { + const out: FoldLine[] = []; + for (const line of block.lines || []) { + out.push({ cover: block.cover ?? null, line, level }); + const sub = line.kind === "daega" ? blocks.get(line.key) : undefined; + if (sub && level < DEPTH && !trail.includes(sub.id)) + out.push(...linesOf(sub, blocks, level + 1, [...trail, sub.id])); + } + return out; +} + +/** 덮개 한 칸 고침 · value null = 그 칸 품셈 값으로 */ +function setCover(ctx: TabContext, at: FoldLine, field: string, value: number | null): void { + if (!at.cover) return; + const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {})); + const lines: Row = all[at.cover] || {}; + const cell: Row = { ...(lines[at.line.cid] || {}) }; + if (value === null) delete cell[field]; + else cell[field] = value; + if (Object.keys(cell).length) lines[at.line.cid] = cell; + else delete lines[at.line.cid]; + if (Object.keys(lines).length) all[at.cover] = lines; + else delete all[at.cover]; + ctx.setUser({ ...ctx.user, daega_cover: all }); +} + +/** 줄 하나 덮개 모두 지움 · 여럿이면 한 번에(다시 그림 한 번) */ +function clearCovers(ctx: TabContext, ats: FoldLine[]): void { + const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {})); + for (const at of ats) { + if (!at.cover || !all[at.cover]) continue; + delete all[at.cover][at.line.cid]; + if (!Object.keys(all[at.cover]).length) delete all[at.cover]; + } + ctx.setUser({ ...ctx.user, daega_cover: all }); +} + +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 { + const blocks = new Map( + (ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]), + ); + const lines = linesOf(block, blocks, 1, [block.id]); + const rows: DataRow[] = lines.map(({ line, level }) => ({ + key: line.cid, + name: `${level > 1 ? "ㄴ " : ""}${line.name || ""}`, + spec: line.spec || "", + qty: line.qty, + unit: line.unit || "", + labor_price: line.prices?.labor, + material_price: line.prices?.material, + expense_price: line.prices?.expense, + amount: line.amount, + base: baseText(line), + note: line.ref_no || line.why || "", + })); + const edited = lines.filter((at) => (at.line.covered || []).length); + const columns = [ + text("name", "품명", 200), + text("spec", "규격", 140), + qty("qty", "품 · 수량"), + text("unit", "단위", 48), + money("labor_price", "노무비", ["단가"], 1), + money("material_price", "재료비", ["단가"], 1), + money("expense_price", "경비", ["단가"], 1), + money("amount", "금액", undefined, 1), + text("base", "품셈 값", 150), + text("note", "비고", 120), + ]; + const table = createDataTable({ + columns, + rows, + total: { name: "합 계", amount: block.sum, note: block.blank ? `빈 줄 ${block.blank}` : "" }, + frozen: 1, + empty: "", + editOnDouble: true, + editable: (i, key) => { + const at = lines[i]; + if (!at?.cover || !(key in FIELD)) return false; + return at.line.kind !== "pct" || key === "qty"; // % 줄 = 율만 + }, + onEdit: (i, key, value) => { + const at = lines[i]; + const field = FIELD[key]; + const v = parseNumber(value); + if (!at || !field || v === undefined) return; + const base = covered(at, field) + ? at.line.base?.[field] + : field === "qty" + ? at.line.qty + : at.line.prices?.[field]; + if (v === null || v === base) { + if (covered(at, field)) setCover(ctx, at, field, null); // 빈칸 · 셈 값 = 품셈 값으로 + return; + } + setCover(ctx, at, field, v); + }, + cellClass: (i, key) => (covered(lines[i], FIELD[key]) ? "is-edited" : null), + level: { key: "name", of: (i) => lines[i]?.level }, + }); + table.root.dataset.b09Fold = ""; + attachHoverInfo( + table.root, + (cell) => { + const key = columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? ""; + const at = lines[Number(cell.parentElement?.dataset.i)]; + const field = FIELD[key]; + if (!at || !field || !covered(at, field)) return null; + return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`; + }, + "tr[data-i] td", + ); + attachRowMenu(table.root, (i) => { + const at = lines[i]; + return at && (at.line.covered || []).length + ? [["품셈 값으로", () => clearCovers(ctx, [at])]] + : []; + }); + const actions = [ + ...(edited.length + ? [ + createButton({ + label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`, + variant: "ghost", + size: "sm", + onClick: () => clearCovers(ctx, edited), + }), + ] + : []), + createButton({ label: "접기", variant: "ghost", size: "sm", onClick: close }), + ]; + const card = createCard({ + title: `${row.name || ""} — 일위대가 구성 (대가${block.no} ${block.name || ""})`, + actions, + pad: 12, + body: [ + table.root, + createText( + "품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈", + "muted", + ), + ], + }); + card.classList.add("b09-fold"); + return card; +} + +/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */ +export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement { + const host = document.createElement("div"); + const draw = (): void => { + const row = raw.find((r) => r.key === open) as Row | undefined; + const ref: Row | undefined = row?.ref; + const block = + ref?.tab === "dg" + ? (ctx.sheets.daega_blocks || []).find((b: Row) => String(b.key) === String(ref.key)) + : undefined; + host.replaceChildren( + ...(row && block + ? [ + foldCard(ctx, row, block, () => { + open = null; + draw(); + }), + ] + : []), + ); + }; + table.addEventListener("click", (event) => { + const target = event.target as Element; + if (target.closest("input, textarea")) return; + const tr = target.closest("tr[data-i]"); + if (!tr || !table.contains(tr)) return; + const row = raw[Number(tr.dataset.i)] as Row | undefined; + open = row?.ref?.tab === "dg" ? String(row.key) : null; + draw(); + }); + draw(); + return host; +} diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 6f9f780d7..b532cd6e6 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -219,4 +219,12 @@ async def save_item_as( async def calc_item( project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session) ) -> dict[str, Any]: - return await _run(copies.calc, await _root(session, project_id), folder) + root = await _root(session, project_id) + out = await _run(copies.calc, root, folder) + from B09_Estimation.B09_Estimation_Labor_Source import cover_count # B09 → M02 순환 피함 + + try: # 702-4 B09 원가에서 고친 일위대가 칸 수(카드 금액 = 사본 셈 · 고치기 전) + out["B09고침"] = await asyncio.to_thread(cover_count, root, folder) + except Exception: # noqa: BLE001 — 표시만 · 카드 셈은 그대로 + out["B09고침"] = 0 + return out diff --git a/resources/tester/test_145_2_b09_labor_calc.py b/resources/tester/test_145_2_b09_labor_calc.py index 53ba3e03e..883a453c8 100644 --- a/resources/tester/test_145_2_b09_labor_calc.py +++ b/resources/tester/test_145_2_b09_labor_calc.py @@ -165,7 +165,7 @@ def test_빈칸_규칙(): assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys -def _ts(daega, sheets, sangeun): +def _ts(daega, sheets, sangeun, cover=None): base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8")) calc = IMPORTS.sub( "", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8") @@ -174,7 +174,8 @@ def _ts(daega, sheets, sangeun): f"\nconst d = {json.dumps(daega, ensure_ascii=False)};" f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};" f"\nconst g = {json.dumps(sangeun, ensure_ascii=False)};" - "\nconst out = computeLabor(d, s, g);" + f"\nconst c = {json.dumps(cover or {}, ensure_ascii=False)};" + "\nconst out = computeLabor(d, s, g, c);" "\nconsole.log(JSON.stringify({out, price: daegaPrice(out, 'pipe@100.0')}));" ) result = subprocess.run( diff --git a/resources/tester/test_702_b09_daega_cover.py b/resources/tester/test_702_b09_daega_cover.py new file mode 100644 index 000000000..18c34d7e9 --- /dev/null +++ b/resources/tester/test_702_b09_daega_cover.py @@ -0,0 +1,240 @@ +"""702-2 · 702-4 B09 일위대가 고친 칸 덮개 — 사용자 값 `daega_cover` +{블록 cover: {줄 cid: {qty · labor · material · expense}}}. + +- 덮개 열쇠 = 블록 `cover` · 줄 `cid`(`B09_Estimation_Labor_Source` · 내용 해시 밖) +- 셈 = `B09_Estimation_Calc_Labor` 파이썬 · TS 짝(거울) — 고친 칸만 셈 값 대신 · `base` = 품셈 값 +- 노임 새 판(바탕 단가 바뀜) · 사본 다시 만듦([재적용]) 뒤에도 덮개 칸 그대로 · 안 고친 칸만 새 값 +- 짝 잃은 덮개 = `daega_orphans`(지우지 않음) · 저장 다듬기 = `B09_Estimation_Store.clean_cover` +견본 = `test_145_2_b09_labor_calc` 엑셀 블록 · 견본 사본. +""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +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_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import cover_count, daega_inputs # noqa: E402 +from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "b145", Path(__file__).with_name("test_145_2_b09_labor_calc.py") +) +b145 = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(b145) + + +def _covered_excel(): + """엑셀 블록 + cover · cid(사본 길이 붙이는 꼴).""" + daega = copy.deepcopy(b145.EXCEL) + for b in daega["blocks"]: + b["cover"] = f"T:{b['id']}" + for n, ln in enumerate(b["lines"]): + ln["cid"] = f"{ln['kind']}:{ln.get('key') or ln['name']}#{n}" + return daega + + +#: DG9 찰붙임 석공 품 0.1 → 0.12 · DG2 % 2 → 3 · DG14 산근 SG10 노무 단가 손 · 짝 잃은 둘 +COVER = { + "T:DG9": {"labor:L00005#0": {"qty": 0.12}}, + "T:DG2": {"pct:공구손료#3": {"qty": 3}}, + "T:DG14": {"sangeun:SG10#2": {"labor": 20000}}, + "T:없는블록": {"labor:L00017#0": {"qty": 1}}, + "T:DG1": {"labor:없는줄#9": {"qty": 1}}, +} + + +def _line(out, bid, n): + return next(b for b in out["daega_blocks"] if b["id"] == bid)["lines"][n] + + +def test_고친_칸만_셈값_대신(): + daega = _covered_excel() + plain = compute_labor(daega, b145.SHEETS, b145.SANGEUN) + out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COVER) + stone = _line(out, "DG9", 0) + assert stone["qty"] == 0.12 and stone["covered"] == ["qty"] + assert stone["base"] == {"qty": 0.1, "labor": 258935, "material": 0, "expense": 0} + assert stone["amounts"]["labor"] == 31072.2 # ROUNDDOWN(258,935 × 0.12, 1) + assert "covered" not in _line(out, "DG9", 1) # 안 고친 줄 = 덧칸 없음 + # 부른 블록이 바뀌면 부르는 블록 금액도(DG14 ← DG9) · 내역 단가도 + nine = next(b for b in out["daega"] if b["key"] == "DG9") + assert nine["labor"] == 42556 + 31072 - 25893 # 석공 줄만 바뀜(ROUNDDOWN 0) + assert daega_price(out, "pipe@100.0")["sum"] != daega_price(plain, "pipe@100.0")["sum"] + pct = _line(out, "DG2", 4) # % 줄 = 끝에 셈(차례 4) + assert pct["qty"] == 3 and pct["base"] == {"qty": 2} and pct["covered"] == ["qty"] + sg = _line(out, "DG14", 2) + assert sg["prices"]["labor"] == 20000 and sg["base"]["labor"] == 18084 + assert sg["covered"] == ["labor"] and sg["prices"]["material"] == 2498 + assert out["daega_orphans"] == [ + {"cover": "T:없는블록", "cid": "labor:L00017#0"}, + {"cover": "T:DG1", "cid": "labor:없는줄#9"}, + ] + assert plain["daega_orphans"] == [] + + +def test_덮개가_빈칸을_채움(): + daega = _covered_excel() + sheets = {**b145.SHEETS, "labor": b145.SHEETS["labor"][:2]} # 석공 노임 없음 + bare = compute_labor(daega, sheets, b145.SANGEUN) + assert _line(bare, "DG9", 0)["amount"] is None + out = compute_labor(daega, sheets, b145.SANGEUN, {"T:DG9": {"labor:L00005#0": {"labor": 1e5}}}) + assert _line(out, "DG9", 0)["amount"] == 10000.0 + assert not [g for g in out["gaps"] if "찰붙임" in g["name"]] + + +@pytest.mark.parametrize("case", ["cover", "none"]) +def test_거울_파이썬_TS(case): + daega = _covered_excel() + cover = COVER if case == "cover" else {} + py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, cover))) + ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, cover) + assert b145._same(py, ts["out"]) + assert b145._same(daega_price(py, "pipe@100.0"), ts["price"]) + + +def test_노임_새_판_덮개_칸_그대로(): + """702-4 — 바탕 단가가 새 판으로 바뀌어도 고친 칸 = 그대로 · 안 고친 칸만 새 값.""" + daega = _covered_excel() + cover = {"T:DG9": {"labor:L00005#0": {"qty": 0.12}, "labor:L00017#1": {"labor": 170000}}} + old = compute_labor(daega, b145.SHEETS, b145.SANGEUN, cover) + new_sheets = { + **b145.SHEETS, + "labor": [{**r, "price": round(r["price"] * 1.05)} for r in b145.SHEETS["labor"]], + } + new = compute_labor(daega, new_sheets, b145.SANGEUN, cover) + a, b = _line(old, "DG9", 0), _line(new, "DG9", 0) + assert a["qty"] == b["qty"] == 0.12 # 고친 품 그대로 + assert b["prices"]["labor"] == round(258935 * 1.05) != a["prices"]["labor"] # 안 고친 단가 = 새 + assert b["base"]["labor"] == b["prices"]["labor"] # 품셈 값 곁 = 새 판 값 + c, d = _line(old, "DG9", 1), _line(new, "DG9", 1) + assert c["prices"]["labor"] == d["prices"]["labor"] == 170000 # 고친 단가 그대로 + assert d["qty"] == 0.03 and d["base"]["labor"] == round(165545 * 1.05) + assert new["daega_orphans"] == [] + + +# ── 사본 → 덮개 열쇠 ─────────────────────────────────────────────────────── + + +@pytest.fixture() +def project(tmp_path) -> Path: + root = b145._project( + tmp_path / "proj", [(100, "SS000901"), (200, "SS000901"), (400, "SS000902")] + ) + b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE)) + b145._snap(root, "pipe@200.0", "SS000901", b145.LINES, b145._values(b145.STONE)) + promoted = [ + {"로직": "GF000219", "승격": True, "수량": {"손": 1}}, + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, + {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, + ] + b145._snap(root, "pipe@400.0", "SS000902", promoted, b145._values(b145.STONE), b145.SC) + return root + + +def test_사본_덮개_열쇠(project): + got = daega_inputs(project) + blocks = {b["id"]: b for b in got["blocks"]} + one = blocks[got["owners"]["pipe@100.0"]] + assert one is blocks[got["owners"]["pipe@200.0"]] # 같은 꼴 = 한 호표 · 처음 본 사본 열쇠 + assert one["cover"] == f"S:{snapshots.folder_name('pipe@100.0')}" + assert [ln["cid"] for ln in one["lines"]] == [ + "sangeun:SG:GF000219#0", + "sangeun:SG:GF000119#0", + "sangeun:SG:GF000143#0", + ] + four = blocks[got["owners"]["pipe@400.0"]] + assert [ln["cid"] for ln in four["lines"]] == [ + "daega:GF000219#0", + "daega:GF000119#0", + "daega:참조|system||SC-T#0", + ] + stone = blocks[four["lines"][0]["key"]] + assert stone["cover"].startswith("P:") and [ln["cid"] for ln in stone["lines"]] == [ + "labor:LB000033#0", + "labor:LB000002#0", + ] + assert blocks[four["lines"][2]["key"]]["cover"] == "C:참조|system||SC-T" + + +def test_재적용_뒤_덮개_남음(project): + """702-4 — 사본을 다시 만들어(재적용 꼴 · 수량 · 줄 차례 바뀜 · 줄 빠짐) 블록 id 가 바뀌어도 + cover · cid 가 같아 고친 칸이 맞는 줄에 붙음 · 사라진 줄 덮개 = 짝 잃음(남김).""" + before = daega_inputs(project) + owner = before["owners"]["pipe@100.0"] + first = next(b for b in before["blocks"] if b["id"] == owner) + cover = { + first["cover"]: { + "sangeun:SG:GF000119#0": {"qty": 0.7}, + "sangeun:SG:GF000143#0": {"labor": 1000}, + } + } + folder = snapshots.structures_dir(project) / snapshots.folder_name("pipe@100.0") + doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8")) + lines = doc["줄"]["담은로직"] + lines[0]["수량"] = {"손": 5} # 설계 바뀜(수량) + doc["줄"]["담은로직"] = [lines[1], lines[0]] # 차례 바뀜 · GF000143 빠짐 + (folder / "일위대가.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + after = daega_inputs(project) + block = next(b for b in after["blocks"] if b["id"] == after["owners"]["pipe@100.0"]) + assert block["id"] != owner and block["cover"] == first["cover"] + cids = [ln["cid"] for ln in block["lines"]] + assert cids == ["sangeun:SG:GF000119#0", "sangeun:SG:GF000219#0"] + sheets = {"labor": [], "material": [], "machine": [], "gaps": []} + out = compute_labor(after, sheets, {}, cover) + got = next(b for b in out["daega_blocks"] if b["id"] == block["id"]) + assert got["lines"][0]["qty"] == 0.7 and got["lines"][0]["covered"] == ["qty"] # 맞는 줄 + assert got["lines"][1]["qty"] == 5 and "covered" not in got["lines"][1] + assert out["daega_orphans"] == [{"cover": first["cover"], "cid": "sangeun:SG:GF000143#0"}] + + +def test_저장_다듬기(): + raw = { + "daega_cover": { + "S:a": {"labor:L1#0": {"qty": 0.5, "labor": "x", "memo": 1}, "b#0": {"qty": None}}, + "S:빈": {}, + "S:c": "글", + "P:d": {"x#0": {"expense": float("inf"), "material": 3}}, + } + } + assert store.clean_user(raw)["daega_cover"] == { + "S:a": {"labor:L1#0": {"qty": 0.5}}, + "P:d": {"x#0": {"material": 3.0}}, + } + assert "daega_cover" not in store.clean_user({"daega_cover": {"S:a": {}}}) + + +def test_저장_뒤_서버_값(tmp_path): + """[저장](write_user → read_user) 뒤 서버 셈 = 화면 짝 셈(덮개 입력값만 받고 다시 셈).""" + stored = store.write_user(tmp_path, {"daega_cover": COVER}) + assert store.read_user(tmp_path)["daega_cover"] == stored["daega_cover"] == COVER + daega = _covered_excel() + server = compute_labor(daega, b145.SHEETS, b145.SANGEUN, stored["daega_cover"]) + screen = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COVER)["out"] + assert b145._same(json.loads(json.dumps(server)), screen) + + +def test_B08_카드_고친_칸_수(project): + """702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록의 덮개 수(카드 금액 = 사본 셈).""" + got = daega_inputs(project) + four = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@400.0"]) + stone = next(b for b in got["blocks"] if b["id"] == four["lines"][0]["key"]) + store.write_user( + project, + { + "daega_cover": { + stone["cover"]: {"labor:LB000033#0": {"qty": 0.3}}, + "S:없음": {"x#0": {"qty": 1}}, + } + }, + ) + assert cover_count(project, snapshots.folder_name("pipe@400.0")) == 1 # 부른 품셈 블록 + assert cover_count(project, snapshots.folder_name("pipe@100.0")) == 0