feat(B09): 집계표 넷 — 재료비 · 노무비 · 경비 수량금액 · 중기시간금액(서버 역전개 · 145-5)

- 역전개(서버 단독) = 설계내역서 수량 × 호표 구성(겹 호표 끝까지 · 깊이 한도) → 자원 총수량 · 내역 자재 줄 그대로
- 끝수 = 엑셀 자리 — 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 중기 비목마다 ROUND · 단가 없으면 금액 빈칸
- 호표 구성 = hopyo_lines 자리(145-1 · 2 가 들어오면 채움) · GET 응답 summary · 화면 탭 넷 + 목록표 이동
- 시험 = test_145_5_b09_summary(엑셀 중기 42.95 시간 5,411,700 · 건축목공 2,222,201 · 각재 280,370 · 못 5,321 · 구역화물 678,345)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:16:58 +09:00
co-authored by Claude Opus 5.5
parent 489a682794
commit 463214d921
5 changed files with 316 additions and 0 deletions
+3
View File
@@ -29,6 +29,7 @@ from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -69,6 +70,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"book": {"made": book.get("made")} if book else None,
"sheets": compute_estimation(inputs, user),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(compute_estimation(inputs, user), hopyo_lines(root)),
}
+124
View File
@@ -0,0 +1,124 @@
"""B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표
(서버 단독).
역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량.
호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}`
(호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음
4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음)
끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) ·
중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합.
단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠).
결과는 GET 때 셈(저장 안 함) · 조작 중엔 다시 안 셈([저장] 뒤 서버 정본).
"""
from __future__ import annotations
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half
#: 겹 호표 깊이 한도(엑셀 최대 대가 → 대가 → 산근 → 중기 = 4) — 돌고 도는 구성 막음
MAX_DEPTH = 8
KINDS = ("material", "labor", "expense", "machine")
def expand(
lines_of: dict[str, Any], key: str, qty: float, out: dict[str, dict[str, float]], depth: int = 0
) -> None:
"""호표 하나 × 수량 → 자원 총수량에 더함(겹 호표는 끝까지 · 깊이 한도 넘으면 멈춤)."""
if depth > MAX_DEPTH:
return
for line in (lines_of.get(key) or {}).get("lines") or []:
q = num(line.get("qty"))
if q is None:
continue
kind = line.get("kind")
if kind == "hopyo":
expand(lines_of, str(line.get("key")), qty * q, out, depth + 1)
elif kind in KINDS:
bucket = out.setdefault(kind, {})
bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q
def totals(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, dict[str, float]]:
"""설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함)."""
out: dict[str, dict[str, float]] = {}
for row in (sheets.get("bill") or {}).get("rows") or []:
qty = num(row.get("qty"))
ref = row.get("ref") or {}
if row.get("kind") != "item" or qty is None or not ref.get("key"):
continue
if ref.get("tab") == "material":
bucket = out.setdefault("material", {})
bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty
else:
expand(lines_of, str(ref["key"]), qty, out)
return out
def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {row["key"]: row for row in rows}
def _resource_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]], unit_default: str):
rows = []
for no, (key, qty) in enumerate(used.items(), 1):
src = listed.get(key) or {}
q = round_half(qty, 2)
price = num(src.get("price"))
rows.append(
{
"no": no,
"key": key,
"name": src.get("name") or key,
"spec": src.get("spec") or "",
"unit": src.get("unit") or unit_default,
"qty": q,
"price": price,
"amount": round_half(q * price, 0) if price is not None else None,
"note": "" if src else "목록표에 없음",
}
)
return rows
def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]):
rows = []
for no, (key, qty) in enumerate(used.items(), 1):
src = listed.get(key) or {}
q = round_half(qty, 2)
parts = {}
for part in ("labor", "material", "expense"):
unit = num(src.get(part))
parts[part] = round_half(q * unit, 0) if unit is not None else None
known = all(v is not None for v in parts.values())
rows.append(
{
"no": no,
"key": key,
"name": src.get("name") or key,
"spec": src.get("spec") or "",
"unit": "시간",
"qty": q,
**parts,
"sum": parts["labor"] + parts["material"] + parts["expense"] if known else None,
"note": "" if src else "목록표에 없음",
}
)
return rows
def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]:
"""집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서)."""
used = totals(sheets, lines_of)
return {
"sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""),
"sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"),
"sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""),
"sum_machine": _machine_sheet(used.get("machine", {}), _list(sheets["machine"])),
}
def hopyo_lines(project_root: Any) -> dict[str, Any]:
"""호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음."""
return {}
+10
View File
@@ -45,6 +45,12 @@ import {
buildMaterial,
} from "./B09_Estimation_UI_Tabs_Base";
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
import {
buildSumExpense,
buildSumLabor,
buildSumMachine,
buildSumMaterial,
} from "./B09_Estimation_UI_Tabs_Summary";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -67,6 +73,10 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
labor: buildLabor,
expense: buildExpense,
compare: buildCompare,
sum_material: buildSumMaterial,
sum_labor: buildSumLabor,
sum_expense: buildSumExpense,
sum_machine: buildSumMachine,
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -0,0 +1,82 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Summary.ts
* B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표(실무 엑셀 꼴).
*
* 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본
* 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
money,
qty,
text,
type TabBuild,
type TabId,
type TabTable,
} from "./B09_Estimation_UI_Tabs";
const HINT =
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
return ({ payload }): TabTable => {
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0);
return {
columns: [
qty("no", "호표", 0),
text("name", "명칭", 200),
text("spec", "규격", 140),
qty("qty", "수량", 2),
text("unit", "단위", 56),
money("price", unitPrice),
money("amount", "금액"),
text("note", "비고", 120),
],
rows,
total: rows.length ? { name: "계", amount: sum } : null,
frozen: 2,
hint: HINT,
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
};
};
}
export const buildSumMaterial = resourceTab("sum_material", "material", "단가");
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가");
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
const add = (key: string) =>
rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0);
return {
columns: [
qty("no", "호표", 0),
text("name", "명칭", 200),
text("spec", "규격", 120),
qty("qty", "수량", 2),
text("unit", "단위", 56),
money("sum", "합계"),
money("labor", "노무비"),
money("material", "재료비"),
money("expense", "경비"),
text("note", "비고", 120),
],
rows,
total: rows.length
? {
name: "계",
sum: add("sum"),
labor: add("labor"),
material: add("material"),
expense: add("expense"),
}
: null,
frozen: 2,
hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`,
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
};
};
@@ -0,0 +1,97 @@
"""145-5 B09 집계표 넷 — 역전개(겹 호표) · 엑셀 끝수 · 검산 대조(서버 단독 · 거울 없음).
견본 = 기번3 공통 엑셀 집계표:
중기시간금액집계표 4줄 불도우저19ton 42.95 시간 → 2,392,315 · 1,584,340 · 1,435,045 = 5,411,700
노무비수량금액집계표 4줄 건축목공 8.29 인 × 268,058 = 2,222,201
재료비수량금액집계표 각재 0.56 × 500,661 = 280,370 · 못 3.54 × 1,503 = 5,321
경비수량금액집계표 구역화물자동차 11.03 회 × 61,500 = 678,345
"""
import asyncio
import sys
from pathlib import Path
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_Router as router_mod # noqa: E402
from B09_Estimation.B09_Estimation_Summary import compute_summary # noqa: E402
SHEETS = {
"labor": [{"key": "L1", "name": "건축목공", "spec": "", "unit": "인", "price": 268058}],
"material": [
{"key": "M3", "name": "각재", "spec": "외송", "unit": "M3", "price": 500661},
{"key": "M4", "name": "못", "spec": "N 50", "unit": "KG", "price": 1503},
],
"expense": [
{"key": "E16", "name": "구역화물자동차", "spec": "5톤", "unit": "회", "price": 61500}
],
"machine": [
{
"key": "X1",
"name": "불도우저19ton",
"spec": "(토사)",
"unit": "시간",
"sum": 126000,
"labor": 55700,
"material": 36888,
"expense": 33412,
},
], # fmt: skip
"bill": {
"rows": [
{"kind": "title", "key": "sec:1", "qty": None},
{"kind": "item", "key": "a", "qty": 10, "ref": {"tab": "sg_list", "key": "A"}},
{"kind": "item", "key": "m", "qty": 0.56, "ref": {"tab": "material", "key": "M3"}},
{"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "A"}},
{"kind": "item", "key": "loop", "qty": 1, "ref": {"tab": "dg_list", "key": "C"}},
]
},
}
LINES = {
"A": {"lines": [
{"kind": "machine", "key": "X1", "qty": 4.295},
{"kind": "hopyo", "key": "B", "qty": 0.5},
{"kind": "material", "key": "M4", "qty": 0.354},
{"kind": "expense", "key": "E16", "qty": 1.103},
]},
"B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658},
{"kind": "labor", "key": "L9", "qty": 1}]},
"C": {"lines": [{"kind": "hopyo", "key": "C", "qty": 1}]}, # 스스로 부름 — 깊이 한도에서 멈춤
} # fmt: skip
def test_역전개_엑셀_금액():
s = compute_summary(SHEETS, LINES)
machine = s["sum_machine"][0]
assert (machine["qty"], machine["labor"], machine["material"], machine["expense"]) == (
42.95, 2392315, 1584340, 1435045,
) # fmt: skip
assert machine["sum"] == 5411700
labor = {r["key"]: r for r in s["sum_labor"]}
assert (labor["L1"]["qty"], labor["L1"]["amount"]) == (
8.29,
2222201,
) # 겹 호표 10 × 0.5 × 1.658
assert labor["L9"]["amount"] is None and labor["L9"]["note"] == "목록표에 없음"
material = {r["key"]: (r["qty"], r["amount"]) for r in s["sum_material"]}
assert material == {"M4": (3.54, 5321), "M3": (0.56, 280370)} # 내역 자재 줄 그대로
assert [(r["qty"], r["amount"]) for r in s["sum_expense"]] == [(11.03, 678345)]
def test_호표_구성_없으면_자재_줄만():
s = compute_summary(SHEETS, {})
assert s["sum_labor"] == [] and s["sum_machine"] == [] and s["sum_expense"] == []
assert [r["key"] for r in s["sum_material"]] == ["M3"]
def test_GET_응답에_집계표(tmp_path, monkeypatch):
async def fake_materials(_project_id):
return {"material": {"rows": [{"key": "sand", "group": "골재", "spec": "모래",
"unit": "㎥", "amount": 16}]}} # fmt: skip
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
body = asyncio.run(router_mod.payload("p", tmp_path))
assert set(body["summary"]) == {"sum_material", "sum_labor", "sum_expense", "sum_machine"}
assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음