diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index c8a3509b4..c445a5f6f 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -17,6 +17,9 @@ 끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. +새 의미 로직 블록(792-4 · `B09_Estimation_Labor_Rule`) = 줄 `place` {방법, 자리}(그 끝수) · + 줄 `out`(비목 합 · `part:` 밑에서 뺌) · 블록 `rule` {끝전 큰나머지, 끝수, 조정}(`settle` · + 엔진 `master_cut.settle` 과 같은 차례). 빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps). 빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 · 줄 0 개 블록 = 빈 줄 하나(`NO_LINES`) · 줄 `ask`(723-4 SC 안 줄 수량 없음) = 수량이 비면 `why`. @@ -33,12 +36,22 @@ from __future__ import annotations from typing import Any -from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total +from B09_Estimation.B09_Estimation_Calc_Base import ( + SUPPLY, + gap, + num, + round_half, + rounddown, + roundup, + total, +) PARTS = ("labor", "material", "expense") COVER_KEYS = ("qty", *PARTS) TAB = "dg" NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름 +COST = {"노무비": "labor", "재료비": "material", "경비": "expense"} +WAYS = {"버림": rounddown, "올림": roundup, "반올림": round_half} COPY = "@copy" ADD = "@add" WHY = { @@ -50,6 +63,35 @@ WHY = { } +def cut(value: float, place: dict | None) -> float: + """줄 금액 끝수 — `place` {방법, 자리}(792-4) · 없으면 ROUNDDOWN 1.""" + if not place: + return rounddown(value, 1) + return WAYS[place.get("방법")](value, int(place.get("자리") or 0)) + + +def settle(raw: dict[str, float], rule: dict | None) -> dict[str, float]: + """블록 비목 합 — 비목마다 ROUNDDOWN 0 · `rule`(792-4) = 큰나머지(계 원 버림 뒤 모자란 원을 + 소수 큰 비목부터 · 같으면 경 › 재 › 노) → `끝수`(대상 계) → `조정`(배율 · 옮김) → 원 버림.""" + parts = {p: rounddown(v, 0) for p, v in raw.items()} + if not rule: + return parts + if rule.get("끝전") == "큰나머지": + rest = int(rounddown(sum(raw.values()), 0) - sum(parts.values())) + for p in sorted(PARTS, key=lambda p: (parts[p] - raw[p], -PARTS.index(p)))[:rest]: + parts[p] += 1 + end = rule.get("끝수") + if isinstance(end, dict) and end.get("대상") == "계": + parts = {p: cut(v, end) for p, v in parts.items()} + adjust = rule.get("조정") or {} + for name, rate in (adjust.get("배율") or {}).items(): + parts[COST[name]] *= float(rate) + if adjust.get("옮김"): + whole = sum(parts.values()) + parts = {p: whole if p == COST[adjust["옮김"]] else 0.0 for p in PARTS} + return {p: rounddown(v, 0) for p, v in parts.items()} + + 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") @@ -97,7 +139,7 @@ def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None: `rows:0,2@labor` 블록 그 차례 줄(`src` cid · `@비목` = 그 몫) 합(778-4 · 짝 `.ts` `pctBase`).""" kind, _, name = spec.partition(":") if kind == "part": - return total([ln["amounts"][name] for ln in lines]) + return total([ln["amounts"][name] for ln in lines if not ln.get("out")]) if kind == "rows": # `3@labor` = 그 줄 그 비목 몫 out: list[float | None] = [] for one in dict.fromkeys(name.split(",")): @@ -151,7 +193,7 @@ def _block( continue 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) + part: None if p is None or qty is None else cut(p * qty, line.get("place")) for part, p in prices.items() } ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음 @@ -177,7 +219,9 @@ def _block( 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) + amounts[part] = ( + None if base is None or pct is None else cut(base * pct / 100, line.get("place")) + ) lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts, **extra}) if amounts[part] is None: gaps.append( @@ -186,8 +230,9 @@ def _block( for line in lines: line["price"] = total(list(line["prices"].values())) line["amount"] = total(list(line["amounts"].values())) - known = [ln for ln in lines if ln["amount"] is not None] - parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS} + known = [ln for ln in lines if ln["amount"] is not None and not ln.get("out")] + parts = settle({part: sum(ln["amounts"][part] for ln in known) for part in PARTS}, + block.get("rule")) # fmt: skip blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None] if not lines: # 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) blank_names = [NO_LINES] diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 4a0582904..9839ebf91 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -21,12 +21,16 @@ * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 * (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy} * 붙인 줄(705-2) = 덮개 @add [블록 줄 fixed · pct · cid @add|…] — 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번) + * 새 의미 로직 블록(792-4) = 줄 place {방법, 자리}(그 끝수) · 줄 out(비목 합 · part: 밑에서 뺌) · + * 블록 rule {끝전 큰나머지, 끝수, 조정}(settle · 짝 `.py` settle) * ========================================================================== */ import { gap, num, + roundHalf, rounddown, + roundup, SUPPLY, total, type Gap, @@ -50,6 +54,49 @@ const WHY: Record = { }; type Parts = Record<(typeof PARTS)[number], Num>; +type Part = (typeof PARTS)[number]; +const COST: Record = { 노무비: "labor", 재료비: "material", 경비: "expense" }; +const WAYS: Record number> = { + 버림: rounddown, + 올림: roundup, + 반올림: roundHalf, +}; + +/** 줄 금액 끝수 — place {방법, 자리}(792-4) · 없으면 ROUNDDOWN 1(짝 `cut`) */ +export function cut(value: number, place: Row | null | undefined): number { + if (!place) return rounddown(value, 1); + return WAYS[place.방법](value, Number(place.자리 || 0)); +} + +/** 블록 비목 합 — 비목마다 ROUNDDOWN 0 · rule(792-4) = 큰나머지 → 끝수(대상 계) → 조정 → 원 버림 + * (짝 `settle`) */ +export function settle( + raw: Record, + rule: Row | null | undefined, +): Record { + const parts = { ...raw }; + for (const p of PARTS) parts[p] = rounddown(raw[p], 0); + if (!rule) return parts; + const sum = (o: Record): number => PARTS.reduce((acc, p) => acc + o[p], 0); + if (rule.끝전 === "큰나머지") { + const rest = rounddown(sum(raw), 0) - sum(parts); + const order = [...PARTS].sort( + (a, b) => parts[a] - raw[a] - (parts[b] - raw[b]) || PARTS.indexOf(b) - PARTS.indexOf(a), + ); + for (const p of order.slice(0, rest)) parts[p] += 1; + } + const end: Row | undefined = rule.끝수; + if (end && typeof end === "object" && end.대상 === "계") + for (const p of PARTS) parts[p] = cut(parts[p], end); + const adjust: Row = rule.조정 || {}; + for (const [name, rate] of Object.entries(adjust.배율 || {})) parts[COST[name]] *= Number(rate); + if (adjust.옮김) { + const whole = sum(parts); + for (const p of PARTS) parts[p] = p === COST[adjust.옮김] ? whole : 0; + } + for (const p of PARTS) parts[p] = rounddown(parts[p], 0); + return parts; +} const zeros = (): Parts => ({ labor: 0, material: 0, expense: 0 }); const blanks = (): Parts => ({ labor: null, material: null, expense: null }); @@ -96,7 +143,7 @@ function pctBase(spec: string, lines: Row[], src: Row[]): Num { const kind = spec.slice(0, at); const name = spec.slice(at + 1); const whole = (ln: Row): Num => total(PARTS.map((p) => ln.amounts[p])); - if (kind === "part") return total(lines.map((ln) => ln.amounts[name])); + if (kind === "part") return total(lines.filter((ln) => !ln.out).map((ln) => ln.amounts[name])); if (kind === "rows") { // `3@labor` = 그 줄 그 비목 몫 const out: Num[] = []; @@ -139,7 +186,7 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: const amounts = blanks(); for (const part of PARTS) { const p = prices[part]; - amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1); + amounts[part] = p === null || qty === null ? null : cut(p * qty, line.place); } const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음 const ask: Row = qty === null && line.ask ? { why: line.ask } : {}; // 723-4 @@ -160,7 +207,7 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: 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); + amounts[part] = base === null || pct === null ? null : cut((base * pct) / 100, line.place); lines.push({ ...line, qty: pct, prices, amounts, ...extra }); if (amounts[part] === null) gaps.push(gap(TAB, b.id, `${b.name} · ${line.name}`, "% 밑수 없음")); @@ -169,13 +216,10 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: line.price = total(PARTS.map((p) => line.prices[p])); line.amount = total(PARTS.map((p) => line.amounts[p])); } - const known = lines.filter((ln) => ln.amount !== null); - const parts = zeros(); - for (const part of PARTS) - parts[part] = rounddown( - known.reduce((acc, ln) => acc + ln.amounts[part], 0), - 0, - ); + const known = lines.filter((ln) => ln.amount !== null && !ln.out); + const raw = { labor: 0, material: 0, expense: 0 }; + for (const part of PARTS) raw[part] = known.reduce((acc, ln) => acc + ln.amounts[part], 0); + const parts = settle(raw, b.rule); const blankNames = lines.length ? lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "") : [NO_LINES]; // 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) diff --git a/B09_Estimation/B09_Estimation_Labor_Rule.py b/B09_Estimation/B09_Estimation_Labor_Rule.py new file mode 100644 index 000000000..0d92c2089 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Labor_Rule.py @@ -0,0 +1,135 @@ +"""B09 대가 블록 셈법(PLAN 792-4) — 791-1 엔진 새 의미(줄 자리 · 합밖 · 번지 · 단계 · 블록 끝전 · +결과 조정)를 쓰는 로직만 블록 줄 · 블록에 표지를 붙임(`B09_Estimation_Labor_Source._poomsem`): + + 줄 `place` {방법, 자리} 줄 금액 끝수(없으면 ROUNDDOWN 1) + 줄 `out` true 비목 합에서 뺌(합밖 줄 · 제외 단계가 담은 줄 · 번지 · 단계엔 쌓임) + 덧줄 = 수 하나 × 밑 합 꼴이면 % 줄 밑 `rows:`(그 줄 차례 · `@비목` · % = 그 수 × 100) — + 밑 = `줄.'이름'` · 앞 덧줄 이름 · `번지.X.비목` · `단계.'이름'.비목` · `노무비` 등(앞 줄 합) + 덧줄 = 이름 없는 수 식 = 고정 줄(수량 1 · 엔진 값) + 블록 `rule` {끝전?, 조정?, 끝수?} 블록 비목 합 = `B09_Estimation_Calc_Labor.settle` +그 밖 꼴(밑마다 다른 수 · 곱 · 중간 이름) = 빈 줄 그대로(대가 단가로 못 셈 · 틀린 값 대신 빈칸). + +셈 = 셈 짝(`B09_Estimation_Calc_Labor` .py · .ts)이 B09 단가로 · 옛 로직(새 칸 없음) = 표지 없음 · +지킴 시험 = `test_792_3_engine_paths.py`(엔진 · SK · SC · B09 산근 · B09 대가 다섯 길 같은 금액). +""" + +from __future__ import annotations + +import importlib +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as _m01 # noqa: F401 — scripts 길을 둠 + +mf = importlib.import_module("master_formula") +ms = importlib.import_module("master_formula_sum") + +PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +FLAGS = ("place", "out") # 블록 줄 덧칸 — 단가산출 블록이 줄을 바꿔도 이어 받음 +Refs = list[tuple[int, str | None]] # (줄 차례, 비목 | None = 줄 전체) + + +def _f(value: Any) -> float | None: + try: + return None if value is None else float(value) + except (TypeError, ValueError): + return None + + +def _cost(rows: list[int], cost: str) -> Refs | None: + if cost == "계": + return [(i, None) for i in rows] + return [(i, PART[cost]) for i in rows] if cost in PART else None + + +def _ref(row: dict, node: tuple, n: int) -> Refs | None: + """밑 이름 하나 → 줄 차례(n 앞만) · 모름 = None.""" + lines = ms.items(row) + names = {x.get("이름"): i for i, x in reversed(list(enumerate(lines[:n]))) if x.get("이름")} + if node[0] == "name": + if node[1] in PART: # 비목 합 = 앞 줄 중 합에 드는 줄 + skip = ms.skipped(row) + return [(i, PART[node[1]]) for i in range(n) if i not in skip] + return [(names[node[1]], None)] if node[1] in names else None + if node[0] != "field": + return None + head, name = node[1], node[2] + if head == ("name", "줄"): + return [(names[name], None)] if name in names else None + if head[0] != "field" or head[1][0] != "name": + return None + group, key = head[1][1], head[2] + if group == ms.ADDR: + return _cost([i for i, x in enumerate(lines[:n]) if x.get(ms.ADDR) == key], name) + if group == ms.STEP: + got = next((mine for st, mine in ms._cover(row) if st.get("이름") == key), None) + return None if got is None else _cost(sorted(i for i in got if i < n), name) + return None + + +def _linear(row: dict, node: tuple, n: int) -> tuple[Refs, Decimal] | None: + """식 = 수 하나 × 밑 합 → (밑, 수) · 다른 꼴 = None.""" + if node[0] in ("name", "field"): + got = _ref(row, node, n) + return None if got is None else (got, Decimal(1)) + if node[0] != "bin": + return None + op, a, b = node[1], node[2], node[3] + if op in ("*", "/") and b[0] == "num" and (left := _linear(row, a, n)): + return left[0], left[1] * b[1] if op == "*" else left[1] / b[1] + if op == "*" and a[0] == "num" and (right := _linear(row, b, n)): + return right[0], a[1] * right[1] + if op == "+" and (left := _linear(row, a, n)) and (right := _linear(row, b, n)): + return (left[0] + right[0], left[1]) if left[1] == right[1] else None + return None + + +def _pct(row: dict, formula: str, n: int) -> tuple[str, float] | None: + """덧줄 식 → (`rows:` 밑, %) · 못 바꿈 = None(밑 없음 · 겹친 밑 포함).""" + try: + got = _linear(row, mf.parse(formula), n) + except mf.FormulaError: + return None + if not got or not got[0] or len(set(got[0])) != len(got[0]): + return None + spec = ",".join(str(i) if part is None else f"{i}@{part}" for i, part in got[0]) + return f"rows:{spec}", float(got[1] * 100) + + +def mark(row: dict, lines: list[dict], engine: list[dict]) -> dict | None: + """새 의미 로직 = 블록 줄에 `place` · `out` · 덧줄 % 밑 · 상수 덧줄(제자리) → 블록 `rule` · + 옛 로직 = None.""" + if not ms.uses(row): + return None + items, skip, heads = ms.items(row), ms.skipped(row), len(row.get("호표") or []) + for n, (item, line) in enumerate(zip(items, lines)): + if item.get("자리"): + line["place"] = dict(item["자리"]) + if n in skip: + line["out"] = True + if n < heads: + continue + formula = str(item.get("식") or "") + part = PART.get(str(item.get("비목")), "expense") + try: + plain = _plain(mf.parse(formula)) + except mf.FormulaError: + continue + value = _f(engine[n].get("금액")) if n < len(engine) else None + if plain and value is not None: # 이름 없는 수 식 = 고정 줄(수량 1) + line.update(kind="fixed", qty=1.0, own={part: value}, link={}, unit="식") + elif got := _pct(row, formula, n): + line.update(kind="pct", part=part, base=got[0], spec=formula, unit="%", qty=got[1]) + else: + continue + line.pop("why", None) + return {k: row[k] for k in ("끝전", "조정", "끝수") if k in row} + + +def _plain(node: tuple) -> bool: + """수 · 사칙 · 부호만 있는 식(이름 없음).""" + if node[0] == "num": + return True + if node[0] == "bin": + return _plain(node[2]) and _plain(node[3]) + return node[0] == "neg" and _plain(node[1]) diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 52835a89a..5c8e8d096 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -34,6 +34,7 @@ from pathlib import Path from typing import Any from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Labor_Rule as rule from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD @@ -149,6 +150,7 @@ class Builder: for line in lines: if line["kind"] == "machine": line["spec"] = (eq.get(line["key"]) or {}).get("규격") or "" + law = rule.mark(logic, lines, engine) # 새 의미 로직 = 줄 자리 · 합밖 · 번지 · 끝전(792-4) unit = str(logic.get("결과단위") or "").replace("원/", "") spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items())) shown = " · ".join( # 157-4 기계 · 직종 원문번호 = 「이름 규격」(번호 = 화면 풍선 spec_raw) @@ -164,6 +166,7 @@ class Builder: "cite": logic.get("출처") or "", "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, "lines": lines, + **({"rule": law} if law else {}), "_engine": engine, "_items": items, } @@ -205,7 +208,7 @@ class Builder: lines = [] for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))): item = block["_items"][n] if n < len(block["_items"]) else {} - qty = _f(x.get("수량")) + qty, keep = _f(x.get("수량")), {k: line[k] for k in rule.FLAGS if k in line} head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""} if isinstance(item.get("참조"), dict) and depth < refs.DEPTH: line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head} @@ -215,7 +218,7 @@ class Builder: own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART} line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}} poom = was.get(ids[n]) if n < len(ids) else None - lines.append({**line, "sk": True, **({"poom": poom} if poom else {})}) + lines.append({**line, **keep, "sk": True, **({"poom": poom} if poom else {})}) block.pop("_items") name = doc.get("이름") or block["name"] origin = {**block["origin"], "단가산출": rid} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 18d1e3c00..868d66ccc 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -269,7 +269,8 @@ def _material_key(whole: Any, item: dict, ref: Any, env: dict, n: int) -> tuple[ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dict]]: - """로직 부르기 줄 — 기계경비 = 중기 노 · 재 · 경 세 줄 · 그 밖 = 하위 산근 한 줄.""" + """로직 부르기 줄 — 기계경비 = 중기 노 · 재 · 경 세 줄 · 그 밖 = 하위 산근 한 줄 · + 금액 끝수 = 줄 `자리`(792-4 · `Sangeun_Sum.places` 와 같음).""" node = mf.parse(item["요소"]) called = node[1] if node and node[0] == "logic" else None try: @@ -284,7 +285,9 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))] out = [] for part in sg.PARTS: - text, money = _line(f"{name} {PART_NAME[part]}", got.get(part), qty_text, q) + text, money = _line( + f"{name} {PART_NAME[part]}", got.get(part), qty_text, q, item.get("자리") + ) out.append((text, {part: money})) return out if not target: @@ -294,7 +297,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic parts = {p: got.get(p) for p in sg.PARTS} if any(v is None for v in parts.values()): return [(f"{name} : {NO_PRICE} * {qty_text}", dict.fromkeys(sg.PARTS))] - amounts = {p: sg._money(float(v), q) for p, v in parts.items()} + amounts = {p: ss._cut(float(v) * q, item.get("자리")) for p, v in parts.items()} head = f"산근 {got['no']}호표 " if got.get("no") else "" unit = sum(float(v) for v in parts.values()) text = f"{name} : {head}{_price(unit)} * {qty_text} = {_money(sum(amounts.values()))}" diff --git a/resources/tester/test_712_3_basis.py b/resources/tester/test_712_3_basis.py index 58093a347..7044e7395 100644 --- a/resources/tester/test_712_3_basis.py +++ b/resources/tester/test_712_3_basis.py @@ -52,7 +52,9 @@ def test_fill_ratio_and_no_write(): ] unit = sum(1 for b in called if b["갈래"] == "단위") from_body = sum(1 for b in called if str(b["기준근거"] or "").startswith("원문")) - assert unit / len(called) >= 0.78 # 로직이 부르는 표 2,023 중 1,601(79 %) + # 로직이 부르는 표 2,117 중 1,626(76.8 %) — 712-3 때 2,023 중 1,601(79 %) · 782 계수 표 94 중 + # 단위 기준 25(시공능력 · 적용기준 계수 = 단위 기준 없음) · 단위 수가 줄면 잡음 + assert unit >= 1601 and unit / len(called) >= 0.76 assert from_body >= 280 # 빈 기준 579 중 원문 머리로 채움 285 assert json.dumps(files, default=str, ensure_ascii=False) == before # 읽기만 diff --git a/resources/tester/test_792_3_engine_paths.py b/resources/tester/test_792_3_engine_paths.py index e3f95ada1..87e946344 100644 --- a/resources/tester/test_792_3_engine_paths.py +++ b/resources/tester/test_792_3_engine_paths.py @@ -1,6 +1,11 @@ -"""792-3 엔진 새 의미 네 길 지킴 시험 — 같은 로직 JSON 이 ① 엔진 직접(`master_cut.engine`) · +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""792-3 · 792-4 엔진 새 의미 다섯 길 지킴 시험 — 같은 로직 JSON 이 +① 엔진 직접(`master_cut.engine`) · ② M02 단가산출 SK 셈(`M02_Sangeun_Calc.run`) · ③ 일위대가 SC 한 줄(수량 1 · `M02_Labor_Doc.calc`) · -④ B09 산근 풀이(`B09_Estimation_Sangeun.Solver` · 단가 = 같은 값의 B09 목록표)에서 같은 금액. +④ B09 산근 풀이(`B09_Estimation_Sangeun.Solver` · 단가 = 같은 값의 B09 목록표) · +⑤ B09 일위대가 대가 블록(SC → 굳힌 SK 참조 수량 1 · `Labor_Source.Builder` → `Calc_Labor` 파이썬 · +TS 짝 · 내역 단가 `daega_price`)에서 같은 금액. +실무 견본(봉화 D00102 · 북부청임도 18)은 793-2 길(가져온 식 SK · 임시 층)로 만들어 ⑤ = 엑셀. 견본 = 791-1 실무 손 옮김 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18 = 엑셀 금액) + 합밖 · 제외 · 조정 단위 로직. 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)도 @@ -9,15 +14,21 @@ import copy import importlib +import json from decimal import Decimal import pytest import resources.tester.test_791_1_engine_semantics as t +from B09_Estimation import B09_Estimation_Calc_Labor as dcalc +from B09_Estimation import B09_Estimation_Labor_Source as dsrc from B09_Estimation import B09_Estimation_Sangeun as sg +from M02_MasterTemplete import M02_Item_Sangeun as item_sk from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as sc from M02_MasterTemplete import M02_Sangeun_Doc as sd +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 mcut = importlib.import_module("master_cut") D = Decimal @@ -26,6 +37,10 @@ PART = {"labor": "노무비", "material": "재료비", "expense": "경비", "sum EQ = "EQ990001" +def ms_uses(row) -> bool: + return importlib.import_module("master_formula_sum").uses(row) + + def _units(): """합밖 · 제외 단계 · 조정 · 큰나머지 · 줄 자리를 한 로직에(791-1 단위 견본 바탕).""" master = t._base( @@ -107,8 +122,46 @@ def _b09(build, row) -> dict: return {PART[p]: D(str(block[p])) for p in PART} +OWNER = "pipe@100.0" # `test_145_2._ts` 가 내역 단가를 읽는 구조물 id + + +def _daega(whole, sk: dict, sheets: dict) -> dict: + """⑤ SC(굳힌 SK 참조 수량 1) → 대가 블록 → 파이썬 셈 = TS 짝 → 내역 단가.""" + from resources.tester.test_145_2_b09_labor_calc import _same, _ts + + sk_ref, sc_ref = {"층": "project", "키": "SK-990001"}, {"층": "project", "키": "SC-990001"} + sc = {"종류": refs.TITLE["precombo"], "이름": "SC", "단위": "식", + "담은로직": [{"참조": sk_ref, "수량": {"손": 1}}]} # fmt: skip + build = dsrc.Builder() + bid = build.precombo(whole, sc_ref, sc, {refs.ref_id(sk_ref): sk, refs.ref_id(sc_ref): sc}) + daega = dsrc._plain({"blocks": list(build.blocks.values()), "owners": {OWNER: bid}}) + py = dcalc.compute_labor(daega, sheets) + ts = _ts(daega, sheets, {}) + assert _same(json.loads(json.dumps(py, ensure_ascii=False)), ts["out"]) # 거울 + price = dcalc.daega_price(py, OWNER) + assert _same(price, ts["price"]) + return {PART[p]: D(str(price[p])) for p in PART} + + +def _b09_daega(build) -> dict: + master, row, _ = build() + sk = {"종류": sd.TITLE, "키": "SK-990001", "이름": "시험", "단위": "식", + "바탕": {"로직": _key(row)}, "입력값": {}, "덮개": {}, "더한줄": [], "뺀줄": [], + item_sk.ROW: row, "정체": []} # fmt: skip + files = master.index + sheets = { + "labor": [{"key": k, "price": float(r["값"])} for k, r in files.get("LB", {}).items()], + "material": [ + {"key": k, "price": float(r["물가자료"])} for k, r in files.get("MT", {}).items() + ], + "machine": [], + "gaps": [], + } + return _daega(master, sk, sheets) + + @pytest.mark.parametrize("name", list(CASES)) -def test_네_길_같은_금액(name): +def test_다섯_길_같은_금액(name): build = CASES[name] master, row, want = build() one = _engine(master, row) @@ -117,6 +170,34 @@ def test_네_길_같은_금액(name): assert _sk(*build()[:2]) == one assert _sc(*build()[:2]) == one assert _b09(build, row) == one + assert _b09_daega(build) == one # ⑤ B09 일위대가 대가 블록(792-4) + + +REAL = [("봉화", "D00102"), ("북부청임도", 18)] + + +@pytest.mark.parametrize(("tag", "code"), REAL) +def test_실무_가져온_식_SK_대가_내역_엑셀(tag, code, world): + """793-2 길 — 경쟁사 블록 → 가져온 식 SK(임시 층) → 굳힘 → SC 줄 → B09 대가 → + 내역 단가 = 엑셀.""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete import M02_Sangeun_Imported as imp + from resources.tester.test_793_2_sangeun_imported import _emitted + + out = _emitted(tag, code) + with store.at(world["personal"]): + made = imp.create(out["로직"], out["원본"], f"{tag} {code}") + doc = store.read("sangeun", made["이름"])["문서"] + whole = m01.loaded()[1] + rid = "project||SK-990001" + frozen = item_sk.freeze({rid: sd.clean(doc, None, whole)})[rid] + key, row = frozen["바탕"]["로직"], json.loads(json.dumps(frozen[item_sk.ROW])) + assert ms_uses(row) # 새 의미 로직(줄 자리 · 번지 · 단계) + books = {"labor": [], "material": [], "machine": [], "gaps": []} # 엑셀 단가 = 고정 줄 + got = _daega(sc._Over(whole, key, row), frozen, books) + excel = {k: D(str(round(v))) for k, v in out["원본"]["엑셀금액"].items()} + assert got == excel def test_단위_로직_새_의미가_금액을_바꿈():