From 969d7f74f30bf0afe51917b3ebea7df9d3b48618 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:41:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?):=20=EA=B3=A0=EC=B9=9C=20=EC=B9=B8=20=EB=8D=AE=EA=B0=9C=20?= =?UTF-8?q?=EC=85=88=20=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B0=92?= =?UTF-8?q?=20daega=5Fcover=20=C2=B7=20=EB=B8=94=EB=A1=9D=20cover=20=C2=B7?= =?UTF-8?q?=20=EC=A4=84=20cid=20=C2=B7=20=ED=8C=8C=EC=9D=B4=EC=8D=AC=C2=B7?= =?UTF-8?q?TS=20=EC=A7=9D=20(PLAN=20702-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 덮개 자리 = B09 사용자 값(user_values.json · 화면 캐시와 같은 user) {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} · 원본 층 · 사본 파일 안 건드림(브레인 승인) - 블록 cover = 조합 S:사본 폴더 · 서고 L:폴더 · 사전조합 C:참조 · 품셈 P:로직+입력 해시 · 줄 cid = 줄 정체(코드 · 산근 로직 · 부른 대가 로직) + n번째 — 내용 해시 밖(호표 id 그대로) - 셈 짝 Calc_Labor.py · .ts — 고친 칸만 셈 값 대신 · 줄 base(품셈 값) · covered · 짝 잃은 덮개 daega_orphans(지우지 않음) · 데이터 점검 알림 - [저장] = 서버가 덮개 입력값만 받아 다시 셈(Store.clean_cover 수만) · 노임 새 판 · [재적용] 뒤에도 남음 - B08 구조물도 카드용 고친 칸 수 cover_count - 시험 test_702_b09_daega_cover(거울 · 판 바꿈 · 재적용 꼴 · 다듬기) · test_145_2 _ts 덮개 인자 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 43 +++- B09_Estimation/B09_Estimation_Calc_Labor.ts | 49 +++- B09_Estimation/B09_Estimation_Labor_Source.py | 59 ++++- B09_Estimation/B09_Estimation_Router.py | 8 +- B09_Estimation/B09_Estimation_Store.py | 20 ++ resources/tester/test_145_2_b09_labor_calc.py | 5 +- resources/tester/test_702_b09_daega_cover.py | 240 ++++++++++++++++++ 7 files changed, 401 insertions(+), 23 deletions(-) create mode 100644 resources/tester/test_702_b09_daega_cover.py 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/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