From a3047f514179f84154d5ee74f99163cca8b45cc0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:28:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(B09):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=EB=AA=A9=EB=A1=9D=ED=91=9C=20=E2=80=94=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=E2=86=92=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=C2=B7=20=EC=82=AC=EC=A0=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=C2=B7=20=ED=92=88=EC=85=88=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20B09=20=EA=B5=B3=ED=9E=8C=20=EB=8B=A8=EA=B0=80=20=C2=B7?= =?UTF-8?q?=20=EC=97=91=EC=85=80=20=EB=81=9D=EC=88=98(145-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원천(서버 단독) = 연결된 사본 → 사본 셈 한 벌(142-1) 수량 ÷ 배 · 같은 원본 + 같은 입력값 = 한 호표(내용 해시) 승격 아닌 로직 = 품셈 호표(노무 · 재료 · 중기 · % 줄) · 승격 · 옛 SP = 산근 참조 · SC = 겹 대가 - 셈 짝(파이썬 · TS) = 줄 ROUNDDOWN 1 · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0 · 관급 · 별도계상 = 대가 안 0원 · 빈칸 규칙 - 내역서 이음 daega_price(sheets, owner) / daegaPrice · 집계표 이음 = 블록 key · 줄 ref.key - 탭 둘(목록표 · 블록표 · 호표 누름 이동 · 사본 없으면 「연결 없음」) · 길 = GET · PUT 셈에 대가 - 단가 수요(used_keys) = 사본 셈과 같은 길(빈 입력 자동값 · 참조사본 안 로직) — 144 시험 옛 단언 고침 - 새 시험 test_145_2_b09_labor_calc(엑셀 대가 1 · 2 · 9 · 14 검산 · 거울 · 견본 사본 · 빈 표 · 이음 꼴) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt --- B09_Estimation/B09_Estimation_Calc_Labor.py | 197 ++++++++++ B09_Estimation/B09_Estimation_Calc_Labor.ts | 184 +++++++++ B09_Estimation/B09_Estimation_Labor_Source.py | 331 ++++++++++++++++ B09_Estimation/B09_Estimation_Linked.py | 20 +- B09_Estimation/B09_Estimation_Router.py | 6 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 9 +- .../B09_Estimation_UI_Tabs_Labor.ts | 126 ++++++ resources/tester/test_144_b09_skeleton.py | 3 +- resources/tester/test_145_2_b09_labor_calc.py | 364 ++++++++++++++++++ 9 files changed, 1226 insertions(+), 14 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Calc_Labor.py create mode 100644 B09_Estimation/B09_Estimation_Calc_Labor.ts create mode 100644 B09_Estimation/B09_Estimation_Labor_Source.py create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts create mode 100644 resources/tester/test_145_2_b09_labor_calc.py diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py new file mode 100644 index 000000000..98af00488 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -0,0 +1,197 @@ +"""B09 일위대가표 · 일위대가목록표 셈(PLAN 145-2) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음. + +⚠ 짝 = `B09_Estimation_Calc_Labor.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + 거울 시험 `resources/tester/test_145_2_b09_labor_calc.py`. + 조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈. + +입력 = 대가 블록(`B09_Estimation_Labor_Source.daega_inputs` — 줄 꼴 · 수량만) + + 바탕 표(144 굳힌 단가): + 노무 줄 = 노무비목록표 단가 · 재료 줄 = 재료비목록표 단가 · + 중기 줄 = 중기목록표(노무 · 재료 · 경비) + 대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값}) + % 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100 +끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · + % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · + 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. +빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(gaps). +""" + +from __future__ import annotations + +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total + +PARTS = ("labor", "material", "expense") +TAB = "dg" +WHY = { + "labor": "노임 단가 없음", + "material": "자재 적용 단가 없음", + "machine": "중기 시간 단가 없음", + "daega": "부른 대가 합계 없음", + "sangeun": "산근 단가 없음(145-1)", +} + + +def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str, float | None]: + """줄 비목 단가 — 그 비목이 없는 줄은 0 · 원천이 없으면 None.""" + kind, key = line["kind"], line.get("key") + out: dict[str, float | None] = dict.fromkeys(PARTS, 0.0) + if kind == "labor": + out["labor"] = num((books["labor"].get(key) or {}).get("price")) + elif kind == "material": + row = books["material"].get(key) or {} + # 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈) + out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row.get("price")) + elif kind in ("machine", "daega", "sangeun"): + row = {"machine": books["machine"], "daega": sums, "sangeun": books["sangeun"]}[kind].get( + key + ) or {} + for part in PARTS: + out[part] = num(row.get(part)) + else: # blank — 못 펼친 줄 + out = dict.fromkeys(PARTS, None) + return out + + +def _base(spec: str, lines: list[dict[str, Any]]) -> float | None: + """% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 · + `line:이름` 그 줄.""" + kind, _, name = spec.partition(":") + if kind == "part": + return total([ln["amounts"][name] for ln in lines]) + if kind == "line": + return next( + (total(list(ln["amounts"].values())) for ln in lines if ln.get("name") == name), None + ) + return total([ln["amounts"]["labor"] for ln in lines if ln["kind"] == "labor"]) + + +def _block( + block: dict[str, Any], + books: dict[str, Any], + sums: dict[str, dict], + gaps: list, +) -> dict[str, Any]: + lines = [] + for line in block["lines"]: + if line["kind"] == "pct": + continue + qty = num(line.get("qty")) + prices = _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}) + if qty is None or None in prices.values(): + why = line.get("why") or ( + "수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음") + ) + gaps.append(gap(TAB, block["id"], f"{block['name']} · {line.get('name') or ''}", why)) + for line in block["lines"]: + if line["kind"] != "pct": + continue + base = _base(line.get("base") or "lines:labor", lines) + pct = num(line.get("qty")) + 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}) + if amounts[part] is None: + gaps.append( + gap(TAB, block["id"], f"{block['name']} · {line.get('name')}", "% 밑수 없음") + ) + for line in lines: + line["price"] = total(list(line["prices"].values())) + line["amount"] = total(list(line["amounts"].values())) + parts: dict[str, float | None] = {} + for part in PARTS: + s = total([ln["amounts"][part] for ln in lines]) + parts[part] = None if s is None else rounddown(s, 0) + return { + **{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite")}, + "key": block["id"], + "lines": lines, + **parts, + "sum": total([parts[p] for p in PARTS]), + } + + +def compute_labor( + daega: dict[str, Any] | None, + sheets: dict[str, Any], + sangeun: dict[str, Any] | None = None, +) -> dict[str, Any]: + """대가 탭 둘 — `daega_blocks`(일위대가표) · `daega`(목록표) · `daega_owners` + + 바탕 gaps 이어 붙임. + + 블록은 부른 블록을 먼저 셈(겹 대가) · 순환은 빈칸 · 번호 = 입력 차례(대가1 …).""" + daega = daega or {} + blocks = daega.get("blocks") or [] + books = { + "labor": {r["key"]: r for r in sheets.get("labor") or []}, + "material": {r["key"]: r for r in sheets.get("material") or []}, + "machine": {r["key"]: r for r in sheets.get("machine") or []}, + "sangeun": sangeun 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]] = {} + gaps: list[dict[str, str]] = [] + + def run(bid: str, trail: tuple[str, ...]) -> None: + if bid in done or bid in trail or bid not in by_id: + return + 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) + + for b in blocks: + run(b["id"], ()) + out_blocks = [] + for b in blocks: + got = {**done[b["id"]], "no": no[b["id"]]} + for line in got["lines"]: + if line["kind"] == "daega" and line.get("key") in no: + line["ref_no"] = f"대가{no[line['key']]}" + out_blocks.append(got) + listing = [ + { + "no": b["no"], + "key": b["id"], + "kind": b["kind"], + "name": b["name"], + "spec": b["spec"], + "unit": b["unit"], + "sum": b["sum"], + "labor": b["labor"], + "material": b["material"], + "expense": b["expense"], + } + for b in out_blocks + ] + return { + "daega_blocks": out_blocks, + "daega": listing, + "daega_owners": dict(daega.get("owners") or {}), + "gaps": [*(sheets.get("gaps") or []), *gaps], + } + + +def daega_price(sheets: dict[str, Any], owner: str) -> dict[str, Any] | None: + """내역서(145-3)가 읽는 대가 단가 — 구조물 id(연결된 사본 주인) → 호표 · 비목 단가. + + 사본이 없으면 None.""" + bid = (sheets.get("daega_owners") or {}).get(owner) + row = next((r for r in sheets.get("daega") or [] if r["key"] == bid), None) + if row is None: + return None + return { + "no": f"대가{row['no']}", + **{k: row[k] for k in ("labor", "material", "expense", "sum")}, + } diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts new file mode 100644 index 000000000..9aa219000 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -0,0 +1,184 @@ +/* ============================================================================= + * B09_Estimation_Calc_Labor.ts + * B09 일위대가표 · 일위대가목록표 셈(PLAN 145-2) — 화면 없음 · 저장 안 함. + * + * ⚠ 짝 = `B09_Estimation_Calc_Labor.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · + * 거울 시험 `resources/tester/test_145_2_b09_labor_calc.py`. + * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. + * + * 노무 줄 = 노무비목록표 · 재료 줄 = 재료비목록표 · 중기 줄 = 중기목록표 · 대가 줄 = 부른 블록 합계 · + * 산근 줄 = 145-1 산근 단가 · % 줄 = 밑수(노무 줄 금액 합) × % / 100 + * 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합 + * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) + * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps) + * ========================================================================== */ + +import { + gap, + num, + rounddown, + SUPPLY, + total, + type Gap, + type Num, + type Row, +} from "./B09_Estimation_Calc_Base"; + +const PARTS = ["labor", "material", "expense"] as const; +const TAB = "dg"; +const WHY: Record = { + labor: "노임 단가 없음", + material: "자재 적용 단가 없음", + machine: "중기 시간 단가 없음", + daega: "부른 대가 합계 없음", + sangeun: "산근 단가 없음(145-1)", +}; + +type Parts = Record<(typeof PARTS)[number], Num>; + +const zeros = (): Parts => ({ labor: 0, material: 0, expense: 0 }); +const blanks = (): Parts => ({ labor: null, material: null, expense: null }); + +function linePrices(line: Row, books: Row, sums: Record): Parts { + const { kind, key } = line; + const out = zeros(); + if (kind === "labor") out.labor = num((books.labor[key] || {}).price); + else if (kind === "material") { + const row: Row = books.material[key] || {}; + // 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈) + out.material = (SUPPLY as readonly string[]).includes(row.supply) ? 0 : num(row.price); + } else if (kind === "machine" || kind === "daega" || kind === "sangeun") { + const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind]; + const row: Row = src[key] || {}; + for (const part of PARTS) out[part] = num(row[part]); + } else return blanks(); // blank — 못 펼친 줄 + return out; +} + +/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 */ +function pctBase(spec: string, lines: Row[]): Num { + const at = spec.indexOf(":"); + const kind = spec.slice(0, at); + const name = spec.slice(at + 1); + if (kind === "part") return total(lines.map((ln) => ln.amounts[name])); + if (kind === "line") { + const hit = lines.find((ln) => ln.name === name); + return hit ? total(PARTS.map((p) => hit.amounts[p])) : null; + } + return total(lines.filter((ln) => ln.kind === "labor").map((ln) => ln.amounts.labor)); +} + +function block(b: Row, books: Row, sums: Record, gaps: Gap[]): 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 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 }); + 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)); + } + } + 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 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 }); + if (amounts[part] === null) + gaps.push(gap(TAB, b.id, `${b.name} · ${line.name}`, "% 밑수 없음")); + } + for (const line of lines) { + line.price = total(PARTS.map((p) => line.prices[p])); + line.amount = total(PARTS.map((p) => line.amounts[p])); + } + const parts = blanks(); + for (const part of PARTS) { + const s = total(lines.map((ln) => ln.amounts[part])); + parts[part] = s === null ? null : rounddown(s, 0); + } + return { + id: b.id, + key: b.id, + kind: b.kind, + name: b.name, + spec: b.spec, + unit: b.unit, + cite: b.cite, + lines, + ...parts, + sum: total(PARTS.map((p) => parts[p])), + }; +} + +/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */ +export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun: Row = {}): Row { + const blocks: Row[] = (daega || {}).blocks || []; + const index = (rows: Row[] | undefined): Row => + Object.fromEntries((rows || []).map((r) => [r.key, r])); + const books: Row = { + labor: index(sheets.labor), + material: index(sheets.material), + machine: index(sheets.machine), + sangeun: sangeun || {}, + }; + const byId: Record = Object.fromEntries(blocks.map((b) => [b.id, b])); + const no: Record = Object.fromEntries(blocks.map((b, i) => [b.id, i + 1])); + const done: Record = {}; + const gaps: Gap[] = []; + 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); + }; + for (const b of blocks) run(b.id, []); + const outBlocks = blocks.map((b) => { + const got: Row = { ...done[b.id], no: no[b.id] }; + for (const line of got.lines) + if (line.kind === "daega" && line.key in no) line.ref_no = `대가${no[line.key]}`; + return got; + }); + const listing = outBlocks.map((b) => ({ + no: b.no, + key: b.id, + kind: b.kind, + name: b.name, + spec: b.spec, + unit: b.unit, + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + })); + return { + daega_blocks: outBlocks, + daega: listing, + daega_owners: { ...((daega || {}).owners || {}) }, + gaps: [...(sheets.gaps || []), ...gaps], + }; +} + +/** 내역서(145-3)가 읽는 대가 단가 — 구조물 id → 호표 · 비목 단가 · 없으면 null */ +export function daegaPrice(sheets: Row, owner: string): Row | null { + const bid = (sheets.daega_owners || {})[owner]; + const row = (sheets.daega || []).find((r: Row) => r.key === bid); + if (!row) return null; + return { + no: `대가${row.no}`, + labor: row.labor, + material: row.material, + expense: row.expense, + sum: row.sum, + }; +} diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py new file mode 100644 index 000000000..d79c9a1e6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -0,0 +1,331 @@ +"""B09 일위대가(대가) 블록 입력(PLAN 145-2) — 서버 단독 · 읽기만 · 값(단가)은 안 씀. + +연결된 사본(`M02_Item_Snapshot.linked_snapshots`)마다 `일위대가.json` 을 사본 셈 한 벌 +(`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침: + + 조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄 + 사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) + 품셈 승격 아닌 로직 줄 = 로직마다 따로(노무 · 재료 · 중기 · % 줄) — 조합이 수량만큼 부름 + 산근 승격 줄 · 옛 참조 SP = 산근 호표 참조(키 = 145-1 `sangeun_key` · 없으면 자리만) + +같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) · +줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) ÷ 배 = 부르는 호표 1 단위 몫. +단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Labor_Doc as labor_doc +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete.M02_Item_Snapshot import ( + MANIFEST_NAME, + PICKS_FILE, + VALUES_FILE, + linked_snapshots, + structures_dir, +) +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json + +mf = item_prices.mf + +#: 엔진 비목 → 블록 비목 +PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +#: 기계 로직 줄 요소 — `로직(GC…, 기계='0201-0070', …)` 의 원문번호 +MACHINE = re.compile(r"기계\s*=\s*['\"]([^'\"]+)['\"]") +#: % 덧줄 밑수 — `노무비 * …` · `줄.'이름' * …` · `(석공 * LB… + …) * …`(인력 줄) +BASE_PART = re.compile(r"^\s*(노무비|재료비|경비)\s*\*") +BASE_LINE = re.compile(r"^\s*줄\.'([^']+)'\s*\*") +BASE_LABOR = re.compile(r"^\s*\(.*\*\s*LB\d+") +AREA_FILE = "면적.json" + + +def _f(value: Any) -> float | None: + if isinstance(value, bool) or value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _digest(value: Any) -> str: + text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(text.encode("utf-8")).hexdigest()[:10] + + +def sangeun_ref(code: str, given: Any, rate: Any, picks: Any) -> str: + """산근 호표 키 — 145-1(`B09_Estimation_Sangeun.sangeun_key`)이 들어오면 그 키 · 전엔 자리만.""" + try: + from B09_Estimation.B09_Estimation_Sangeun import sangeun_key + except ImportError: + return f"SG:{code}:{_digest([given or {}, rate, picks or {}])}" + return sangeun_key(code, given or {}, rate, picks or {}) + + +class Builder: + """한 프로젝트 블록 모음 — 같은 id 는 한 번만(처음 본 차례).""" + + def __init__(self) -> None: + self.blocks: dict[str, dict[str, Any]] = {} + self.gaps: list[dict[str, str]] = [] + + def put(self, block: dict[str, Any]) -> str: + bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}" + if bid not in self.blocks: + self.blocks[bid] = {"id": bid, **block} + return bid + + # ── 품셈(로직 하나) ───────────────────────────────────────────────── + def poomsem(self, whole: Any, one: dict[str, Any]) -> str | None: + code = str(one.get("로직") or "") + given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름") + try: + logic = whole.logic(code) + except mf.FormulaError: + return None + got = labor_doc.run_line(whole, code, given, rate, picks) + if got.get("기준") is None: + return None # 돈 아닌 로직 · 못 돈 로직 — 조합 줄이 까닭을 냄 + items = logic.get("호표") or [] + lines = [] + engine = got.get("줄") or [] + bases = _engine_bases(engine[: len(items)], items) + for n, x in enumerate(engine): + item = items[n] if n < len(items) else None + lines.append(self._engine_line(whole, item, x, logic, n - len(items), bases)) + unit = str(logic.get("결과단위") or "").replace("원/", "") + spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items())) + return self.put( + { + "kind": "품셈", + "name": logic.get("이름") or code, + "spec": spec, + "unit": unit, + "cite": logic.get("출처") or "", + "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, + "lines": lines, + } + ) + + def _engine_line( + self, whole: Any, item: dict | None, x: dict, logic: dict, extra: int, bases: dict + ) -> dict[str, Any]: + head = {"name": x.get("이름") or "", "spec": "", "unit": x.get("단위") or ""} + qty = _f(x.get("수량")) + goods = x.get("품목") or {} + if item is None: # 덧줄 — 밑수 × % / 100(% = 엔진 값 ÷ 엔진 밑수) + add = (logic.get("덧줄") or [])[extra] + base, label = _base_of(str(add.get("식") or "")) + value, under = _f(x.get("금액")), bases.get(base) if base else None + if value is None or not under: + # ponytail: 밑수 없는 덧줄(손료 식 등)은 B09 단가로 못 셈 — 줄만 두고 빈칸 + return {**head, "kind": "blank", "qty": qty, "why": "덧줄 — 대가 단가로 못 셈"} + part = PART.get(str(add.get("비목")), "expense") + return {**head, "kind": "pct", "part": part, "base": base, "spec": f"{label}의 %", + "unit": "%", "qty": round(value / under * 100, 6)} # fmt: skip + kind = item.get("종류") + if kind == "인력": + key = goods.get("키") or str(item.get("요소") or "") + return {**head, "kind": "labor", "key": key, "qty": qty} + if kind == "재료": + if not goods.get("키"): + return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"} + return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "", + "qty": qty} # fmt: skip + hit = MACHINE.search(str(item.get("요소") or "")) + row = whole.numbers.get("EQ", {}).get(hit.group(1)) if hit else None + if row is None: + # ponytail: 기계 아닌 하위 로직 줄은 B09 단가로 못 펼침 — 줄만 두고 빈칸 + return { + **head, + "kind": "blank", + "qty": qty, + "why": "하위 로직 줄 — 대가 단가로 못 펼침", + } + return {**head, "kind": "machine", "key": row["키"], "spec": row.get("규격") or "", + "qty": qty} # fmt: skip + + # ── 조합 · 사전조합 줄 ────────────────────────────────────────────── + def line_of( + self, whole: Any, one: dict, qty: Any, times: Any, name: str, copies: dict, depth: int = 0 + ) -> dict[str, Any]: + """담은 로직 줄 하나 → 블록 줄 — 수량 = 수량 ÷ 배(부르는 호표 1 단위 몫).""" + q = _f(qty) + t = _f(times) or 1.0 + each = None if q is None else q / t + ref = one.get("참조") + if isinstance(ref, dict): + doc = copies.get(refs.ref_id(ref)) + if not isinstance(doc, dict): + return { + "kind": "blank", + "name": name, + "qty": each, + "why": f"없는 참조 「{ref.get('키')}」", + } + if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 산근 + first = (doc.get("담은로직") or [{}])[0] + key = sangeun_ref(str(first.get("로직") or ""), first.get("입력값"), + first.get("켜쌓기율"), first.get("고름")) # fmt: skip + return {"kind": "sangeun", "key": key, "name": doc.get("이름") or name, + "unit": doc.get("단위") or "", "qty": each} # fmt: skip + bid = self.precombo(whole, ref, doc, copies, depth + 1) + return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, + "unit": doc.get("단위") or "", "qty": each} # fmt: skip + code = str(one.get("로직") or "") + if one.get("승격") is True: + key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) + return {"kind": "sangeun", "key": key, "name": name, "qty": each} + bid = self.poomsem(whole, one) + if bid is None: + return { + "kind": "blank", + "name": name or code, + "qty": each, + "why": "돈 아닌 로직 · 못 돈 로직", + } + block = self.blocks[bid] + return {"kind": "daega", "key": bid, "name": block["name"], "spec": block["spec"], + "unit": block["unit"], "qty": each} # fmt: skip + + def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str: + """사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + pills = { + code: pill + for pill in combo_store.combo_pills({"담은로직": inner})["물량"] + for code in pill["로직"] + } + lines = [] + for one in inner: + hand = _f((one.get("수량") or {}).get("손")) + pill = pills.get(one.get("로직")) or {} + times = pill.get("배") if "로직" in one else 1 + name = pill.get("이름") or str(one.get("로직") or "") + if depth >= refs.DEPTH and isinstance(one.get("참조"), dict): + lines.append({"kind": "blank", "name": name, "qty": hand, "why": "참조 깊이 넘음"}) + continue + qty = 1 if hand is None else hand + lines.append(self.line_of(whole, one, qty, times, name, copies, depth)) + return self.put( + { + "kind": "사전조합", + "name": doc.get("이름") or ref.get("키"), + "spec": str(ref.get("키") or ""), + "unit": doc.get("단위") or "", + "cite": "", + "origin": {"참조": refs.ref_id(ref)}, + "lines": lines, + } + ) + + # ── 조합(구조물 사본) ─────────────────────────────────────────────── + def combo(self, folder: Path) -> str | None: + labor = read_json(folder / LABOR_FILE) + if not isinstance(labor, dict): + return None + prices = read_json(folder / PRICE_FILE) or {} + values = read_json(folder / VALUES_FILE) or {} + picks = read_json(folder / PICKS_FILE) or {} + whole = item_prices.load_prices(prices) + result = item_prices.calc(labor, prices, values, picks, read_json(folder / AREA_FILE)) + doc = item_prices.with_values(labor.get("줄") or {}, values, picks) + copies = labor.get("참조사본") or {} + lines = [] + for one, done in zip(doc.get("담은로직") or [], result.get("줄") or []): + name = done.get("이름") or str(one.get("로직") or "") + pill = next( + (p for p in result.get("알약") or [] if p.get("키") == done.get("알약")), {} + ) + line = self.line_of( + whole, one, done.get("수량"), done.get("배"), pill.get("이름") or name, copies + ) + if line.get("qty") is None and line["kind"] != "blank": + line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} + lines.append(line) + for mat in result.get("자재") or []: + row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} + lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "", + "spec": row.get("규격") or "", "unit": row.get("단위") or "", + "qty": _f(mat.get("수량"))}) # fmt: skip + manifest = read_json(folder / MANIFEST_NAME) or {} + origin = manifest.get("원본") or {} + return self.put( + { + "kind": "조합", + "name": origin.get("name") or " ".join(origin.get("경로") or []) or folder.name, + "spec": " › ".join(origin.get("경로") or []), + "unit": (labor.get("줄") or {}).get("단위") or "", + "cite": "", + "origin": {k: origin.get(k) for k in ("layer", "owner", "key")}, + "lines": lines, + } + ) + + +def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: + """엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`.""" + out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()} + out["lines:labor"] = 0.0 + for x, item in zip(engine, items): + for name, value in (x.get("비목") or {}).items(): + if name in PART: + out[f"part:{PART[name]}"] += _f(value) or 0 + if item.get("종류") == "인력": + out["lines:labor"] += _f(x.get("금액")) or 0 + if x.get("이름"): + out[f"line:{x['이름']}"] = _f(x.get("금액")) or 0 + return out + + +def _base_of(formula: str) -> tuple[str | None, str]: + """덧줄 식 → (밑수 갈래, 이름) — 모르는 꼴이면 (None, "").""" + if hit := BASE_PART.match(formula): + return f"part:{PART[hit.group(1)]}", hit.group(1) + if hit := BASE_LINE.match(formula): + return f"line:{hit.group(1)}", hit.group(1) + if BASE_LABOR.match(formula): + return "lines:labor", "인건비" + return None, "" + + +def _plain(value: Any) -> Any: + if isinstance(value, Decimal): + return float(value) + if isinstance(value, dict): + return {k: _plain(v) for k, v in value.items()} + if isinstance(value, list): + return [_plain(v) for v in value] + return value + + +def daega_inputs(project_root: str | Path) -> dict[str, Any]: + """대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것. + + 차례 = 조합(구조물 id 차례) → 사전조합 → 품셈(처음 본 차례) — 호표 번호 = 이 차례. + """ + base = structures_dir(project_root) + build = Builder() + owners: dict[str, str] = {} + for owner, linked in sorted(linked_snapshots(project_root).items()): + if "일위대가" not in linked.get("부분", []): + continue + try: + bid = build.combo(base / linked["폴더"]) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음 + if bid: + owners[owner] = bid + order = {"조합": 0, "사전조합": 1, "품셈": 2} + blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]]) + return _plain({"blocks": blocks, "owners": owners}) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index e3495f1d1..ded22e253 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete.M02_Item_Snapshot import ( PICKS_FILE, VALUES_FILE, @@ -46,15 +47,16 @@ def used_keys(folder: Path) -> list[str]: return row snapshot.get = get # 이 사본 묶음 한 벌에만 — 마스터 캐시는 안 건드림 - for key in item_prices.mcb.logic_keys(labor.get("줄") or {}): - if not key: - continue - given = {name: (one or {}).get("값") for name, one in (values.get(key) or {}).items()} - try: - with mf.mp.choosing((picks or {}).get(key)): - mf.run(snapshot, key, given) - except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): - continue # 못 돈 로직 — 부른 데까지만 + # 사본 셈 한 벌(142-1)과 같은 길 — 빈 입력 = 자동값 · 켜쌓기율 · 고름 · + # 굳힌 참조사본 안 로직까지 + doc = item_prices.with_values(labor.get("줄") or {}, values, picks) + inner = [d for d in (labor.get("참조사본") or {}).values() if isinstance(d, dict)] + for one in [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]: + # 못 돈 로직 — 부른 데까지만(까닭은 안 봄) + if isinstance(one, dict) and one.get("로직"): + labor_doc.run_line( + snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) return seen diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 7ac339c94..9e4a99cbf 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -26,6 +26,8 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation import B09_Estimation_Store as store from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -52,12 +54,14 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: ) book = await asyncio.to_thread(store.read_book, root) inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"]) + inputs["daega"] = await asyncio.to_thread(daega_inputs, root) user = await asyncio.to_thread(store.read_user, root) + base = compute_base(inputs, user) return { "inputs": inputs, "user": user, "book": {"made": book.get("made")} if book else None, - "sheets": compute_base(inputs, user), + "sheets": {**base, **compute_labor(inputs["daega"], base)}, "notes": notes, } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index c90e24ec7..830d170c2 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -25,6 +25,8 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; +import { computeLabor } from "./B09_Estimation_Calc_Labor"; +import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { buildCheck, rowIndex, @@ -49,14 +51,14 @@ function L(key: keyof typeof ui_locales): string { } const LATER: Record = { - dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김", - dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김", sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김", sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김", }; const BUILDERS: Partial> = { check: buildCheck, + dg_list: buildDaegaList, + dg: buildDaegaBlocks, basis: buildBasis, machine: buildMachine, machine_blocks: buildMachineBlocks, @@ -89,7 +91,8 @@ function note(textValue: string): HTMLElement { /** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */ export function computeSheets(payload: Row, user: Row): Row { - return computeBase(payload.inputs, user); + const base = computeBase(payload.inputs, user); + return { ...base, ...computeLabor(payload.inputs.daega, base) }; } export function renderB09Estimation(root: HTMLElement): void { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts new file mode 100644 index 000000000..8114f1b5e --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -0,0 +1,126 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Labor.ts + * B09 대가 탭 둘(PLAN 145-2) — 일위대가목록표 · 일위대가표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Labor`). + * + * 목록표 = 호표 한 줄(합계 · 노무비 · 재료비 · 경비) · 줄 누름 = 그 블록으로 + * 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록) + * 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」 + * ========================================================================== */ + +import type { DataRow } from "@ui/ui_template_data_table"; +import type { Row } from "./B09_Estimation_Calc_Base"; +import { + money, + qty, + text, + type Link, + type TabBuild, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; + +const EMPTY = + "연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]"; +const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; + +/** 블록 줄 → 단가를 낸 탭 */ +const LINE_TAB: Record = { + labor: "labor", + material: "material", + machine: "machine", + daega: "dg", + sangeun: "sg_list", +}; + +export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { + const rows: DataRow[] = (sheets.daega || []).map((r: Row) => ({ ...r, no: `대가${r.no}` })); + if (!rows.length) return EMPTY; + return { + columns: [ + text("no", "호표", 80), + text("kind", "꼴", 70), + text("name", "명칭", 240), + text("spec", "규격", 200), + text("unit", "단위", 56), + money("sum", "합계"), + money("labor", "노무비"), + money("material", "재료비"), + money("expense", "경비"), + ], + rows, + frozen: 3, + hint: "호표 = 일위대가표 블록 합계 · 줄을 누르면 그 블록으로", + link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null), + }; +}; + +export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { + const blocks: Row[] = sheets.daega_blocks || []; + if (!blocks.length) return EMPTY; + const rows: DataRow[] = []; + const links: Array = []; + for (const b of blocks) { + rows.push({ + key: b.id, + name: `제 ${b.no} 호표 ${b.name}`, + spec: b.spec, + unit: b.unit, + note: b.kind, + }); + links.push(null); + if (b.cite) { + rows.push({ key: `${b.id}|cite`, name: ` ${b.cite}`, note: "품셈 인용" }); + links.push(null); + } + b.lines.forEach((ln: Row, n: number) => { + rows.push({ + key: `${b.id}|${n}`, + name: ` ${ln.name || ""}`, + spec: ln.spec || "", + qty: ln.qty, + unit: ln.unit || "", + sum_price: ln.price, + sum: ln.amount, + labor_price: ln.prices.labor, + labor: ln.amounts.labor, + material_price: ln.prices.material, + material: ln.amounts.material, + expense_price: ln.prices.expense, + expense: ln.amounts.expense, + note: ln.ref_no || ln.why || "", + }); + const tab = LINE_TAB[ln.kind]; + links.push(tab && ln.key ? { tab, key: String(ln.key) } : null); + }); + rows.push({ + key: `${b.id}|합계`, + name: " 합 계", + sum: b.sum, + labor: b.labor, + material: b.material, + expense: b.expense, + }); + links.push(null); + } + const pair = (key: string, label: string) => [ + money(`${key}_price`, "단가", [label], 1), + money(key, "금액", [label], 1), + ]; + return { + columns: [ + text("name", "명칭", 240), + text("spec", "규격", 160), + qty("qty", "수량"), + text("unit", "단위", 56), + ...pair("sum", "합계"), + ...pair("labor", "노무비"), + ...pair("material", "재료비"), + ...pair("expense", "경비"), + text("note", "비고", 120), + ], + rows, + frozen: 1, + hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, + link: (i: number) => links[i] ?? null, + }; +}; diff --git a/resources/tester/test_144_b09_skeleton.py b/resources/tester/test_144_b09_skeleton.py index 23cfd7ec7..ed3cdce47 100644 --- a/resources/tester/test_144_b09_skeleton.py +++ b/resources/tester/test_144_b09_skeleton.py @@ -130,5 +130,6 @@ def test_연결된_사본이_부른_자원만(tmp_path): (tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"] (tmp_path / "입력값.json").write_text("{}", encoding="utf-8") - assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음 + # 빈 입력 = 자동값(142-1 사본 셈 한 벌 · 기계 고르기 첫 값 1730-0015) — 그 로직이 부른 줄 + assert used_keys(tmp_path) == ["EQ000112", "MO000001", "LB000050"] assert used_keys(tmp_path / "없음") == [] diff --git a/resources/tester/test_145_2_b09_labor_calc.py b/resources/tester/test_145_2_b09_labor_calc.py new file mode 100644 index 000000000..52c7792a1 --- /dev/null +++ b/resources/tester/test_145_2_b09_labor_calc.py @@ -0,0 +1,364 @@ +"""145-2 B09 일위대가표 · 일위대가목록표 — 엑셀 검산 대조 · +거울(`B09_Estimation_Calc_Labor.py` ↔ `.ts`) · +견본 사본 → 대가 블록(`B09_Estimation_Labor_Source`). + +엑셀 견본 = 기번3 공통 xlsx 「일위대가표」 같은 입력: + 대가1 모르터바르기 109,259 · 대가2 파형강관부설 D800 16,001(15,002 · 584 · 415 · 공구손료 2 %) · + 대가9 찰붙임 52,943(42,556 · 3,842 · 6,545 · 중기 줄) · + 대가14 돌붙임(채집) 조합 101,522(77,755 · 9,519 · 14,248 · 산근 11 · 10 · 30 + 대가 9 · 1 겹) +""" + +import json +import re +import shutil +import subprocess +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_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 +from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +# ── 엑셀 같은 입력 ───────────────────────────────────────────────────────── +SHEETS = { + "labor": [ + {"key": "L00017", "price": 165545}, # 보통인부 + {"key": "L00032", "price": 243168}, # 배관공(수도) + {"key": "L00005", "price": 258935}, # 석공 + ], + "material": [ + {"key": "M00101", "price": 120, "supply": "별도"}, # 시멘트 별도계상 → 대가 안 0원 + {"key": "M00103", "price": 25000, "supply": "별도"}, # 모래 + {"key": "M00222", "price": 99000, "supply": "관급"}, # 파형강관(800) + ], + "machine": [ + {"key": "X00044", "labor": 47231, "material": 7784, "expense": 9994}, + {"key": "X00020", "labor": 55700, "material": 18296, "expense": 31168}, + ], + "gaps": [], +} +SANGEUN = { + "SG11": {"labor": 18289, "material": 5101, "expense": 7008}, + "SG10": {"labor": 18084, "material": 2498, "expense": 4234}, + "SG30": {"labor": 66071, "material": 1008, "expense": 304}, +} + + +def _l(kind, key, qty, name="", **extra): + return {"kind": kind, "key": key, "qty": qty, "name": name or key, **extra} + + +def _b(bid, kind, name, lines): + return { + "id": bid, + "kind": kind, + "name": name, + "spec": "", + "unit": "", + "cite": "", + "lines": lines, + } + + +EXCEL = { + "blocks": [ + _b("DG14", "조합", "돌붙임(채집)", [ + _l("sangeun", "SG11", 1), _l("daega", "DG9", 1), _l("sangeun", "SG10", 0.15), + _l("sangeun", "SG30", 0.2), _l("daega", "DG1", 0.009), + ]), + _b("DG1", "품셈", "모르터바르기", [ + _l("material", "M00101", 510), _l("material", "M00103", 1.1), + _l("labor", "L00017", 0.66), + ]), + _b("DG2", "품셈", "파형강관부설", [ + _l("material", "M00222", 1), _l("labor", "L00032", 0.04), _l("labor", "L00017", 0.02), + {"kind": "pct", "name": "공구손료", "part": "material", "base": "lines:labor", + "qty": 2}, + _l("machine", "X00044", 0.0416), + ]), + _b("DG9", "품셈", "찰붙임", [ + _l("labor", "L00005", 0.1), _l("labor", "L00017", 0.03), _l("machine", "X00020", 0.21), + ]), + ], + "owners": {"pipe@100.0": "DG14"}, +} # fmt: skip + + +def _block(out, bid): + return next(b for b in out["daega_blocks"] if b["id"] == bid) + + +def _sums(block): + return (block["sum"], block["labor"], block["material"], block["expense"]) + + +def test_엑셀_대가_블록_검산(): + out = compute_labor(EXCEL, SHEETS, SANGEUN) + assert _sums(_block(out, "DG1")) == (109259, 109259, 0, 0) + assert _sums(_block(out, "DG2")) == (16001, 15002, 584, 415) + assert _sums(_block(out, "DG9")) == (52943, 42556, 3842, 6545) + assert _sums(_block(out, "DG14")) == (101522, 77755, 9519, 14248) + pct = next(ln for ln in _block(out, "DG2")["lines"] if ln["kind"] == "pct") + assert ( + pct["prices"]["material"] == pytest.approx(13037.6) and pct["amounts"]["material"] == 260.7 + ) + mortar = _block(out, "DG14")["lines"][-1] + assert mortar["ref_no"] == "대가2" and mortar["amounts"]["labor"] == 983.3 # 109,259 × 0.009 + assert _block(out, "DG1")["lines"][0]["amounts"]["material"] == 0 # 별도계상 = 대가 안 0원 + # 목록표 = 블록 합계 · 내역서가 읽는 대가 단가 + assert out["daega"][0] == { + "no": 1, "key": "DG14", "kind": "조합", "name": "돌붙임(채집)", "spec": "", "unit": "", + "sum": 101522, "labor": 77755, "material": 9519, "expense": 14248, + } # fmt: skip + assert daega_price(out, "pipe@100.0") == { + "no": "대가1", + "labor": 77755, + "material": 9519, + "expense": 14248, + "sum": 101522, + } + assert daega_price(out, "pipe@999.0") is None + assert out["gaps"] == [] + + +def _blank_cases(): + broken = json.loads(json.dumps(EXCEL)) + broken["blocks"][2]["lines"][1]["key"] = "L09999" # 노임 없음 → 그 블록 · 부른 블록 빈칸 아님 + broken["blocks"].append(_b("DGX", "조합", "순환", [_l("daega", "DGY", 1)])) + broken["blocks"].append(_b("DGY", "조합", "순환2", [_l("daega", "DGX", 1)])) + broken["blocks"].append(_b("DGZ", "품셈", "수량 없음", [_l("labor", "L00017", None)])) + return broken + + +def test_빈칸_규칙(): + out = compute_labor(_blank_cases(), SHEETS, {}) + assert _block(out, "DG2")["sum"] is None and _block(out, "DG2")["labor"] is None + assert _block(out, "DG14")["sum"] is None # 산근 단가 없음(145-1 전) + assert _block(out, "DGZ")["sum"] is None + assert _block(out, "DGX")["sum"] is None or _block(out, "DGY")["sum"] is None # 순환 = 빈칸 + whys = {g["why"] for g in out["gaps"]} + assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys + + +def _ts(daega, sheets, sangeun): + 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") + ) + body = ( + 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);" + "\nconsole.log(JSON.stringify({out, price: daegaPrice(out, 'pipe@100.0')}));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", base + calc + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _same(a, b): + """JSON 거울 비교 — 수는 값으로(0 · 0.0 같음).""" + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b)) + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return abs(a - b) < 1e-9 + return a == b + + +@pytest.mark.parametrize("case", ["excel", "blank"]) +def test_거울_파이썬_TS(case): + daega, sangeun = (EXCEL, SANGEUN) if case == "excel" else (_blank_cases(), {}) + py = json.loads(json.dumps(compute_labor(daega, SHEETS, sangeun), ensure_ascii=False)) + ts = _ts(daega, SHEETS, sangeun) + assert _same(py, ts["out"]) + assert _same(daega_price(py, "pipe@100.0"), ts["price"]) + + +# ── 견본 사본 → 대가 블록 ─────────────────────────────────────────────────── +STONE = {"뒷길이": 45, "돌": "깬돌", "쌓기": "켜쌓기", "높이": 2} +LINES = [ + {"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기(인력 줄) + {"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설(중기 줄) + {"로직": "GF000143", "수량": {"손": 1}}, # 케이블 크레인 운반대(재료 · 재료비 % 줄) +] +SC = { + "참조|system||SC-T": { + "이름": "사전조합T", + "단위": "m3", + "담은로직": [{"로직": "GF000219", "입력값": STONE, "수량": {"손": 3}}], + } +} + + +def _item(key): + return {"layer": "system", "owner": None, "key": key, "name": f"조합 {key}"} + + +def _values(given): + return {"GF000219": {k: {"값": v} for k, v in given.items()}} + + +def _snap(root, owner, key, lines, values, copies=None): + folder = snapshots.structures_dir(root) / snapshots.folder_name(owner) + folder.mkdir(parents=True) + write = lambda name, data: (folder / name).write_text( # noqa: E731 + json.dumps(data, ensure_ascii=False), encoding="utf-8" + ) + codes = [one["로직"] for one in lines if "로직" in one] + for doc in (copies or {}).values(): + codes += [one["로직"] for one in doc["담은로직"] if "로직" in one] + write(snapshots.MANIFEST_NAME, {"원본": {**_item(key), "경로": ["기슭막이", key]}}) + write("단가.json", item_prices.to_json(item_prices.collect(codes))) + doc = {"종류": "일위대가", "단위": "m", "담은로직": lines, "자재": []} + write("일위대가.json", {"줄": doc, **({"참조사본": copies} if copies else {})}) + write("입력값.json", values) + + +def _project(root: Path, points) -> Path: + shutil.rmtree(root, ignore_errors=True) + edits = root / "B04_PreProcess/drainage/edits" + edits.mkdir(parents=True) + rows = [{"chainage_m": c, "options": {}, "summary_item": _item(k), "source": "user"} + for c, k in points] # fmt: skip + (edits / "pipe_points.json").write_text(json.dumps({"points": rows}), encoding="utf-8") + route = root / "B05_Profile/route" + route.mkdir(parents=True) + (route / "structures.json").write_text('{"revision": 1, "structures": []}', encoding="utf-8") + return root + + +@pytest.fixture(scope="module") +def project(tmp_path_factory) -> Path: + root = _project( + tmp_path_factory.mktemp("p145") / "proj", + [(100, "SS000901"), (200, "SS000901"), (300, "SS000901"), (400, "SS000902"), + (500, "SS000903")], + ) # fmt: skip + _snap(root, "pipe@100.0", "SS000901", LINES, _values(STONE)) + _snap(root, "pipe@200.0", "SS000901", LINES, _values(STONE)) # 같은 원본 + 같은 입력값 + _snap(root, "pipe@300.0", "SS000901", LINES, _values({**STONE, "높이": 5})) # 입력값 다름 + promoted = [ + {"로직": "GF000219", "승격": True, "수량": {"손": 1}}, + {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, + ] + _snap(root, "pipe@400.0", "SS000902", promoted, _values(STONE), SC) + # 500 = 지정만 있고 사본 없음 → 없음 + return root + + +def test_견본_사본_대가_블록(project): + got = daega_inputs(project) + owners = got["owners"] + assert set(owners) == {"pipe@100.0", "pipe@200.0", "pipe@300.0", "pipe@400.0"} + assert ( + owners["pipe@100.0"] == owners["pipe@200.0"] != owners["pipe@300.0"] + ) # 같은 원본+입력 = 한 호표 + blocks = {b["id"]: b for b in got["blocks"]} + assert [b["kind"] for b in got["blocks"]] == ["조합"] * 3 + ["사전조합"] + ["품셈"] * 4 + a = blocks[owners["pipe@100.0"]] + assert [(ln["kind"], ln["qty"]) for ln in a["lines"]] == [ + ("daega", 2), + ("daega", 0.5), + ("daega", 1), + ] + stone, digger, crane = (blocks[ln["key"]] for ln in a["lines"]) + assert [(ln["kind"], ln["key"]) for ln in stone["lines"]] == [ + ("labor", "LB000033"), + ("labor", "LB000002"), + ] + assert stone["cite"] == "산림품셈 13-4-1" and stone["unit"] == "㎡" + assert digger["lines"][0]["kind"] == "machine" and digger["lines"][0]["key"].startswith("EQ") + pct = crane["lines"][-1] + assert pct["kind"] == "pct" and pct["base"] == "part:material" and pct["qty"] == 20 + b = blocks[owners["pipe@400.0"]] + assert b["lines"][0]["kind"] == "sangeun" and b["lines"][0]["key"].startswith("SG:GF000219:") + sc = blocks[b["lines"][1]["key"]] + assert sc["kind"] == "사전조합" and b["lines"][1]["qty"] == 4 + assert ( + sc["lines"][0]["key"] == stone["id"] and sc["lines"][0]["qty"] == 3 + ) # 겹 대가 = 같은 품셈 + + +def test_견본_사본_끝까지_셈(project): + keys = linked_resource_keys(project) + inputs = prices.build_inputs(None, keys) + inputs["daega"] = daega_inputs(project) + base = compute_base(inputs, {}) + out = compute_labor(inputs["daega"], base) + owner = inputs["daega"]["owners"]["pipe@100.0"] + combo = _block(out, owner) + assert combo["sum"] is not None and combo["no"] == 1 + # 조합 줄 = 부른 품셈 합계 × 수량(ROUNDDOWN 1) · 합계 = ROUNDDOWN 0 + for ln in combo["lines"]: + child = _block(out, ln["key"]) + assert ln["prices"]["labor"] == child["labor"] + price = daega_price(out, "pipe@100.0") + assert price["no"] == "대가1" and price["sum"] == combo["sum"] + assert daega_price(out, "pipe@500.0") is None # 사본 없음 = 없음 + assert [g for g in out["gaps"] if g["tab"] == "dg" and g["key"] == owner] == [] + + +def test_사본_없으면_빈_표(tmp_path): + root = _project(tmp_path / "empty", [(100, "SS000901")]) + got = daega_inputs(root) + assert got == {"blocks": [], "owners": {}} + out = compute_labor(got, {"gaps": []}) + assert out["daega"] == [] and out["daega_blocks"] == [] and out["daega_owners"] == {} + + +def _block_lines(blocks): + """145-5 집계표 이음 꼴 — 짝 = `B09_Estimation_Summary.block_lines`(들어오면 그것을 씀).""" + try: + from B09_Estimation.B09_Estimation_Summary import block_lines + except ImportError: + kinds = {"labor": "labor", "material": "material", "expense": "expense", + "machine": "machine", "sangeun": "hopyo", "daega": "hopyo"} # fmt: skip + return { + str(b["key"]): { + "lines": [ + {"kind": kinds[ln["kind"]], "key": ln["ref"]["key"], "qty": ln["qty"]} + for ln in b["lines"] + if ln["kind"] in kinds + and (ln.get("ref") or {}).get("key") + and ln["qty"] is not None + ] + } + for b in blocks + } + return block_lines(blocks) + + +def test_집계표_이음_꼴(): + """블록 key = 대가 id(= daega_owners 값 · 내역서 대가 ref) · 줄 ref.key · 1 단위당 qty.""" + out = compute_labor(EXCEL, SHEETS, SANGEUN) + lines = _block_lines(out["daega_blocks"]) + assert set(lines) == {"DG14", "DG1", "DG2", "DG9"} + assert out["daega_owners"]["pipe@100.0"] in lines + assert lines["DG14"]["lines"] == [ + {"kind": "hopyo", "key": "SG11", "qty": 1}, + {"kind": "hopyo", "key": "DG9", "qty": 1}, + {"kind": "hopyo", "key": "SG10", "qty": 0.15}, + {"kind": "hopyo", "key": "SG30", "qty": 0.2}, + {"kind": "hopyo", "key": "DG1", "qty": 0.009}, + ] + assert {"kind": "machine", "key": "X00044", "qty": 0.0416} in lines["DG2"]["lines"] + assert all(ln["kind"] != "pct" for ln in lines["DG2"]["lines"]) # % 줄 = 자원 아님