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:
@@ -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")},
|
||||
}
|
||||
@@ -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})
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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"]) # % 줄 = 자원 아님
|
||||
Reference in New Issue
Block a user