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:
@@ -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)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음
|
||||
Reference in New Issue
Block a user