feat(B09): 일위대가표 · 일위대가목록표 — 사본 일위대가 → 조합 · 사전조합 · 품셈 블록 · B09 굳힌 단가 · 엑셀 끝수(145-2)

- 원천(서버 단독) = 연결된 사본 → 사본 셈 한 벌(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
This commit is contained in:
2026-10-09 15:28:45 +09:00
co-authored by Claude Opus 5.5
parent a976ff1145
commit a3047f5141
9 changed files with 1226 additions and 14 deletions
+197
View File
@@ -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")},
}
+184
View File
@@ -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<string, string> = {
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<string, Row>): 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<string, Row>, 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<string, Row> = Object.fromEntries(blocks.map((b) => [b.id, b]));
const no: Record<string, number> = Object.fromEntries(blocks.map((b, i) => [b.id, i + 1]));
const done: Record<string, Row> = {};
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,
};
}
@@ -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})
+11 -9
View File
@@ -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
+5 -1
View File
@@ -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,
}
+6 -3
View File
@@ -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<string, string> = {
dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김",
dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김",
sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김",
sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김",
};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
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 {
@@ -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<string, TabId> = {
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<Link | null> = [];
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,
};
};
+2 -1
View File
@@ -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 / "없음") == []
@@ -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"]) # % 줄 = 자원 아님