Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_6

# Conflicts:
#	B09_Estimation/B09_Estimation_Router.py
#	B09_Estimation/B09_Estimation_UI_Shell.ts
This commit is contained in:
2026-10-09 15:20:41 +09:00
11 changed files with 1158 additions and 10 deletions
+152
View File
@@ -0,0 +1,152 @@
"""B09 설계내역서 항목 짜기(PLAN 145-3) — 서버 단독 · B08 수량 → 내역 줄(수량 · 단가 자리).
공종 나무 = 엑셀 기번3 공통 꼴 고정(`B09_Estimation_Calc_Bill.SECTIONS`) · 공구 안 나눔.
토공(1.1~1.6) = B08 토공집계표 줄 짝(`EARTH` — 엑셀 항목 이름 ↔ 토공집계표 구분 · 종류 · 규격) ·
단가 = 조합 밖 토공 산근 · 준비공 대가 — 원천 없음 → 빈칸 「연결 없음」(지시)
구조물(2.1~2.3) = B08 구조물 집계표 열(사본 있을 때만 · 143) ·
구조물군 → 공종 짝(`STRUCTURE_SECTION`)
· 커플링 열은 자재(관급자재대)라 뺌 · 단가 = 대가 호표(145-2 가 `hopyo` 로 줌 · 그전엔 빈칸)
운반(3) = 엑셀 운반공 줄 · 수량 = B08 총괄자재 짝 · 거리 · 단가 = 운반 산근(원천 없음 → 빈칸)
자재대 · 관급자재대 = 화면 짝이 대비표 「별도」 · 「관급」 표시로 짬(사용자 조작 즉시)
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
WHY_READY = "준비공 대가 — 연결 없음"
WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)"
WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)"
#: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None)
EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
("1.1", "규준틀설치 종단규준틀", "개소", None),
("1.1", "규준틀설치 횡단규준틀", "개소", None),
("1.1", "제근", "㎡", None),
("1.2", "절토(토사)", "㎥", ("흙깍기", "토사", None)),
("1.2", "절토(연암)", "㎥", ("흙깍기", "연암", None)),
("1.2", "암발파", "㎥", ("흙깍기", "경암", None)),
("1.3", "구조물터파기(토사)", "㎥", ("구조물터파기", "토사", None)),
("1.3", "구조물터파기(연암)", "㎥", ("구조물터파기", "연암", None)),
("1.3", "구조물 되메우기", "㎥", ("되메우기", "", None)),
("1.4", "측구파기(토사)", "㎥", ("측구 터파기", "토사", None)),
("1.4", "측구파기(연암)", "㎥", ("측구 터파기", "연암", None)),
("1.5", "유용토운반", "㎥", ("유용토 소계", "", None)),
("1.5", "도자운반", "㎥", ("토사운반", "", "19 TON B/D")),
("1.5", "덤프운반", "㎥", ("토사운반", "", "BH+DT+BH")),
("1.6", "사토처리", "㎥", ("잔토처리", "", None)),
("1.6", "성토사면다짐", "㎡", ("성토면다짐", "", None)),
("1.6", "노면정리", "㎡", None),
]
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"}
SKIP_COLUMNS = ("pipe:cp|",)
#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None)
HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [
("중기운반", "대", None),
("기계운반", "대", None),
("모래운반", "㎥", ("sand",)),
("자갈운반", "㎥", ("gravel",)),
("시멘트운반", "대", ("cement",)),
("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")),
]
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
group, kind, spec = want
same = row.get("group") == group and (row.get("kind") or "") == kind
return same and (spec is None or row.get("spec") == spec)
def earth_items(earthwork: dict[str, Any] | None) -> list[dict[str, Any]]:
rows = (earthwork or {}).get("summary") or []
out = []
for n, (section, name, unit, want) in enumerate(EARTH, 1):
source = next((r for r in rows if want and _same(r, want)), None)
out.append(
{
"key": f"earth:{n}",
"section": section,
"name": name,
"spec": (source or {}).get("spec") or "",
"unit": unit,
"qty": source.get("value") if source else None,
"note": "B08 토공집계표" if source else "",
"ref": None,
"why": WHY_READY if section == "1.1" else WHY_EARTH,
}
)
return out
def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
columns = (summary or {}).get("columns") or []
total = (summary or {}).get("total") or {}
out = []
for column in columns:
cid = str(column.get("id") or "")
if cid.startswith(SKIP_COLUMNS):
continue
section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2")
name, spec, unit = _structure_row(column)
combos = [c for c in column.get("combos") or [] if c]
out.append(
{
"key": f"st:{cid}",
"section": section,
"name": name,
"spec": spec,
"unit": unit,
"qty": total.get(cid),
"note": " · ".join(combos),
"ref": {"tab": "dg_list", "key": cid},
"why": WHY_STRUCTURE,
}
)
return out
def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]:
rows = ((materials or {}).get("material") or {}).get("rows") or []
amount = {row.get("key"): row.get("amount") for row in rows}
out = []
for n, (name, unit, keys) in enumerate(HAUL, 1):
qty = None
if keys:
found = [amount[k] for k in keys if amount.get(k) is not None]
qty = round(sum(found), 6) if found else None
out.append(
{
"key": f"haul:{n}",
"section": "3",
"name": name,
"spec": "",
"unit": unit,
"qty": qty,
"note": "B08 총괄자재" if qty is not None else "",
"ref": None,
"why": WHY_HAUL,
}
)
return out
def bill_items(b08: dict[str, Any]) -> dict[str, Any]:
return {
"items": [
*earth_items(b08.get("earthwork")),
*structure_items(b08.get("summary")),
*haul_items(b08.get("materials")),
]
}
def hopyo_prices(project_root: Any) -> dict[str, dict[str, float]]:
"""호표 단가 `{호표 자리 키: {labor, material, expense}}` —
145-1 · 2(산근 · 대가)가 들어오면 채움 · 그전엔 빈 묶음(구조물 단가 = 빈칸 「연결 없음」)."""
return {}
+208
View File
@@ -0,0 +1,208 @@
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
단가 = 호표(대가 · 산근 — 145-1 · 2 가 `inputs.hopyo` 로 줌) · 자재 = 대비표 적용 단가
금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 합계 = 세 비목 합 ·
조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작)
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
from __future__ import annotations
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, rounddown
#: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔)
SECTIONS: list[tuple[str, str, str | None]] = [
("1", "토공사", None),
("1.1", "준비공", "1"),
("1.2", "절토", "1"),
("1.3", "구조물터파기 및 되메우기", "1"),
("1.4", "측구파기", "1"),
("1.5", "유용성토", "1"),
("1.6", "사토 및 다짐공", "1"),
("2", "구조물공", None),
("2.1", "돌기슭막이공", "2"),
("2.2", "배수공", "2"),
("2.3", "기타구조물", "2"),
("3", "운반공", None),
("4", "자재대", None),
("G", "관급자재대", None),
]
#: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액)
DIRECT = ("1", "2", "3", "4")
PARTS = ("labor", "material", "expense")
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
if prices is None:
unit = {p: None for p in PARTS}
else:
unit = {p: num(prices.get(p)) for p in PARTS}
out: dict[str, Any] = {f"price_{p}": unit[p] for p in PARTS}
known = all(unit[p] is not None for p in PARTS)
out["price_sum"] = unit["labor"] + unit["material"] + unit["expense"] if known else None
for p in PARTS:
out[p] = rounddown(qty * unit[p], 0) if qty is not None and unit[p] is not None else None
filled = all(out[p] is not None for p in PARTS)
out["sum"] = out["labor"] + out["material"] + out["expense"] if filled else None
return out
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None, gaps: list) -> dict[str, Any]:
qty = num(item.get("qty"))
row = {
"kind": "item",
"key": item["key"],
"section": item["section"],
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": qty,
"note": item.get("note") or "",
"ref": item.get("ref"),
**_amounts(qty, prices),
}
if qty is None:
gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
if prices is None:
gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음"))
return row
def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]:
"""대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목."""
qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m}
out = []
for row in compare:
if row["supply"] is None or row["key"] not in qty_of:
continue
out.append(
{
"key": f"mat:{row['key']}",
"section": "G" if row["supply"] == "관급" else "4",
"name": row["name"],
"spec": row["spec"],
"unit": row["unit"],
"qty": qty_of[row["key"]],
"note": f"자재 {row['no']}",
"ref": {"tab": "material", "key": row["key"]},
"_price": row["applied"],
}
)
return out
def compute_bill(
inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any]
) -> dict[str, Any]:
gaps: list[dict[str, str]] = []
hopyo = inputs.get("hopyo") or {}
items: list[dict[str, Any]] = []
for item in (inputs.get("bill") or {}).get("items") or []:
ref = item.get("ref") or {}
prices = hopyo.get(ref.get("key")) if ref.get("key") else None
items.append(_item_row(item, prices, gaps))
for item in _material_items(inputs, base["compare"]):
price = item["_price"]
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
items.append(_item_row(item, prices, gaps))
owner = [row for row in items if row["section"] == "G"]
net = 0.0
for row in owner:
net += row["material"] or 0
pct = num(((user.get("estimate") or {}).get("procurement_fee_pct")))
fee = rounddown(net * pct / 100, 0) if pct is not None else None
fee_row = {
"kind": "item",
"key": "fee",
"section": "G",
"name": "조달수수료",
"spec": "관급 재료비 × 율",
"unit": "%",
"qty": pct,
"note": "관급 순자재대 밑수",
"ref": None,
"price_labor": 0,
"price_material": net,
"price_expense": 0,
"price_sum": net,
"labor": 0 if pct is not None else None,
"material": fee,
"expense": 0 if pct is not None else None,
"sum": fee,
}
if owner:
items.append(fee_row)
if pct is None:
gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"))
rows: list[dict[str, Any]] = []
sums: dict[str, dict[str, Any]] = {}
for code, name, parent in SECTIONS:
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
for row in items:
code = row["section"]
chain = [code]
parent = next(p for c, _n, p in SECTIONS if c == code)
if parent:
chain.append(parent)
for c in chain:
for p in ("sum", *PARTS):
sums[c][p] += row[p] or 0
if row["sum"] is None:
sums[c]["blank"] += 1
for code, name, parent in SECTIONS:
s = sums[code]
rows.append(
{
"kind": "title",
"key": f"sec:{code}",
"section": code,
"name": f"{code} {name}" if code != "G" else name,
"sum": s["sum"],
"labor": s["labor"],
"material": s["material"],
"expense": s["expense"],
"blank": s["blank"],
"note": ("(제외금액)" if code == "G" else "")
+ (f" 빈 줄 {s['blank']}" if s["blank"] else ""),
}
)
rows.extend(row for row in items if row["section"] == code)
direct = {p: 0.0 for p in ("sum", *PARTS)}
blank = 0
for code in DIRECT:
for p in direct:
direct[p] += sums[code][p]
blank += sums[code]["blank"]
totals = {
"direct": {**direct, "blank": blank},
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
}
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
known = blank == 0
owner_known = all(row["sum"] is not None for row in owner)
sums_out = {
"material": direct["material"] if known else None,
"labor": direct["labor"] if known else None,
"expense": direct["expense"] if known else None,
"supply_net": net if owner_known else None,
}
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
def compute_estimation(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
base = compute_base(inputs, user)
bill = compute_bill(inputs, user, base)
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}
+197
View File
@@ -0,0 +1,197 @@
/* =============================================================================
* B09_Estimation_Calc_Bill.ts
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
*
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
*
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
* ========================================================================== */
import {
computeBase,
gap,
num,
rounddown,
type Gap,
type Num,
type Row,
} from "./B09_Estimation_Calc_Base";
/** 공종 나무 — [번호, 이름, 부모] */
export const SECTIONS: Array<[string, string, string | null]> = [
["1", "토공사", null],
["1.1", "준비공", "1"],
["1.2", "절토", "1"],
["1.3", "구조물터파기 및 되메우기", "1"],
["1.4", "측구파기", "1"],
["1.5", "유용성토", "1"],
["1.6", "사토 및 다짐공", "1"],
["2", "구조물공", null],
["2.1", "돌기슭막이공", "2"],
["2.2", "배수공", "2"],
["2.3", "기타구조물", "2"],
["3", "운반공", null],
["4", "자재대", null],
["G", "관급자재대", null],
];
const DIRECT = ["1", "2", "3", "4"];
const PARTS = ["labor", "material", "expense"];
function amounts(qty: Num, prices: Row | null): Row {
const unit: Record<string, Num> = {};
for (const p of PARTS) unit[p] = prices === null ? null : num(prices[p]);
const out: Row = {};
for (const p of PARTS) out[`price_${p}`] = unit[p];
const known = PARTS.every((p) => unit[p] !== null);
out.price_sum = known ? unit.labor! + unit.material! + unit.expense! : null;
for (const p of PARTS)
out[p] = qty !== null && unit[p] !== null ? rounddown(qty * unit[p]!, 0) : null;
const filled = PARTS.every((p) => out[p] !== null);
out.sum = filled ? out.labor + out.material + out.expense : null;
return out;
}
function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row {
const qty = num(item.qty);
const row: Row = {
kind: "item",
key: item.key,
section: item.section,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty,
note: item.note || "",
ref: item.ref ?? null,
...amounts(qty, prices),
};
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
return row;
}
function materialItems(inputs: Row, compare: Row[]): Row[] {
const qtyOf: Row = {};
for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty;
const out: Row[] = [];
for (const row of compare) {
if (row.supply === null || !(row.key in qtyOf)) continue;
out.push({
key: `mat:${row.key}`,
section: row.supply === "관급" ? "G" : "4",
name: row.name,
spec: row.spec,
unit: row.unit,
qty: qtyOf[row.key],
note: `자재 ${row.no}`,
ref: { tab: "material", key: row.key },
_price: row.applied,
});
}
return out;
}
export function computeBill(inputs: Row, user: Row, base: Row): Row {
const gaps: Gap[] = [];
const hopyo: Row = inputs.hopyo || {};
const items: Row[] = [];
for (const item of (inputs.bill || {}).items || []) {
const ref: Row = item.ref || {};
const prices = ref.key ? (hopyo[ref.key] ?? null) : null;
items.push(itemRow(item, prices, gaps));
}
for (const item of materialItems(inputs, base.compare)) {
const price: Num = item._price;
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(item, prices, gaps));
}
const owner = items.filter((row) => row.section === "G");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
const fee = pct !== null ? rounddown((net * pct) / 100, 0) : null;
const feeRow: Row = {
kind: "item",
key: "fee",
section: "G",
name: "조달수수료",
spec: "관급 재료비 × 율",
unit: "%",
qty: pct,
note: "관급 순자재대 밑수",
ref: null,
price_labor: 0,
price_material: net,
price_expense: 0,
price_sum: net,
labor: pct !== null ? 0 : null,
material: fee,
expense: pct !== null ? 0 : null,
sum: fee,
};
if (owner.length) {
items.push(feeRow);
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
}
const sums: Record<string, Row> = {};
for (const [code] of SECTIONS)
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
for (const row of items) {
const code = row.section;
const chain = [code];
const parent = SECTIONS.find(([c]) => c === code)![2];
if (parent) chain.push(parent);
for (const c of chain) {
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
if (row.sum === null) sums[c].blank += 1;
}
}
const rows: Row[] = [];
for (const [code, name] of SECTIONS) {
const s = sums[code];
rows.push({
kind: "title",
key: `sec:${code}`,
section: code,
name: code !== "G" ? `${code} ${name}` : name,
sum: s.sum,
labor: s.labor,
material: s.material,
expense: s.expense,
blank: s.blank,
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
});
rows.push(...items.filter((row) => row.section === code));
}
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const code of DIRECT) {
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
blank += sums[code].blank;
}
const totals = {
direct: { ...direct, blank },
owner: { net_material: net, fee, total: net + (fee || 0) },
};
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
const known = blank === 0;
const ownerKnown = owner.every((row) => row.sum !== null);
const sumsOut = {
material: known ? direct.material : null,
labor: known ? direct.labor : null,
expense: known ? direct.expense : null,
supply_net: ownerKnown ? net : null,
};
return { rows, totals, sums: sumsOut, gaps };
}
/** 144 바탕 + 145-3 설계내역서 */
export function computeEstimation(inputs: Row, user: Row): Row {
const base = computeBase(inputs, user);
const bill = computeBill(inputs, user, base);
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
}
+20 -7
View File
@@ -21,14 +21,17 @@ from uuid import UUID
from fastapi import APIRouter, Body
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
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_Bill_Items import bill_items, hopyo_prices
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
from B09_Estimation.B09_Estimation_Calc_Total import compute_total
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -36,11 +39,17 @@ router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
out: dict[str, Any] = {}
try:
out["materials"] = await material_sheets(project_id)
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}")
out["materials"] = None
for key, name, read in (
("materials", "자재 집계표", material_sheets),
("earthwork", "토공집계표", earthwork_table),
):
try:
out[key] = await read(project_id)
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}")
out[key] = None
# 구조물 집계표 = 자재 집계표가 이미 부른 값(열 · 계 · 조합) — 두 번 안 부름
out["summary"] = (out["materials"] or {}).get("source")
return out
@@ -54,8 +63,10 @@ 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["bill"] = bill_items(b08)
inputs["hopyo"] = hopyo_prices(root)
user = await asyncio.to_thread(store.read_user, root)
sheets = compute_base(inputs, user)
sheets = compute_estimation(inputs, user)
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
return {
@@ -65,6 +76,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"sheets": sheets,
"rate_candidates": rate_candidates(),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(sheets, 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 {}
+15 -2
View File
@@ -24,7 +24,8 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
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 type { Row } from "./B09_Estimation_Calc_Base";
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
import { computeTotal } from "./B09_Estimation_Calc_Total";
import {
buildCheck,
@@ -44,6 +45,13 @@ import {
buildMachineBlocks,
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";
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
function L(key: keyof typeof ui_locales): string {
@@ -59,6 +67,7 @@ const LATER: Record<string, string> = {
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
check: buildCheck,
bill: buildBill,
basis: buildBasis,
machine: buildMachine,
machine_blocks: buildMachineBlocks,
@@ -68,6 +77,10 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
compare: buildCompare,
cost: buildCost,
total: buildTotal,
sum_material: buildSumMaterial,
sum_labor: buildSumLabor,
sum_expense: buildSumExpense,
sum_machine: buildSumMachine,
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -93,7 +106,7 @@ function note(textValue: string): HTMLElement {
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
export function computeSheets(payload: Row, user: Row): Row {
const sheets = computeBase(payload.inputs, user);
const sheets = computeEstimation(payload.inputs, user);
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) };
}
@@ -0,0 +1,63 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill.ts
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
*
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
import {
money,
parseNumber,
qty,
text,
type TabBuild,
type TabId,
type TabTable,
} from "./B09_Estimation_UI_Tabs";
export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
const bill: Row = sheets.bill || { rows: [], totals: null };
const rows: DataRow[] = bill.rows.map((r: Row) => ({
...r,
name: r.kind === "title" ? r.name : ` ${r.name}`,
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
money(`price_${key}`, "단가", [label]),
money(key, "금액", [label]),
];
return {
columns: [
text("name", "공종 · 명칭", 220),
text("spec", "규격", 160),
qty("qty", "수량", 3),
text("unit", "단위", 56),
...pair("sum", "합계"),
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("note", "비고", 200),
],
rows,
total: { name: "순공사비 계(관급 제외)", ...direct },
frozen: 1,
hint:
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
editable: (i, key) => key === "qty" && rows[i]?.key === "fee",
onEdit: (i, key, value) => {
if (key !== "qty" || rows[i]?.key !== "fee") return;
const v = parseNumber(value);
if (v === undefined) return;
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
},
link: (i) => {
const ref = rows[i]?.ref as Row | null | undefined;
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
},
};
};
@@ -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),
};
};
+1 -1
View File
@@ -106,7 +106,7 @@ def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
assert [(g["tab"], g["key"]) for g in sheets["gaps"] if g["tab"] == "compare"] == [
("compare", "b08:sand"),
("compare", "b08:cement"),
]
+199
View File
@@ -0,0 +1,199 @@
"""145-3 B09 설계내역서 — 엑셀 검산 대조 · 항목 짜기 · 원가계산서 이음 꼴 ·
거울(`Calc_Bill.py` ↔ `.ts`).
견본 = 기번3 공통 엑셀 설계내역서:
9 · 10줄 규준틀(대가 5 · 8) · 14줄 절토(토사)(산근 13) — 같은 호표 단가면 같은 금액
246~253줄 관급자재대 — 순자재대 69,474,220 · 조달수수료 0.54 % = 375,160 · 계 69,849,380
"""
import json
import re
import subprocess
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.B09_Estimation_Bill_Items import bill_items # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
#: 관급 자재(엑셀 246~252 · 재료비목록표 단가) — [키, 품명, 규격, 단위, 수량, 단가]
OWNER = [
("b08:remicon_25_18_80", "레미콘", "25-18-80", "M3", 32, 102970),
("b08:pipe_800", "파형강관", "Φ800m/m, 2.7t", "M", 212, 157000),
("b08:pipe_1000", "파형강관", "Φ1000m/m, 2.7t", "M", 132, 196270),
("b08:pipe_1200", "파형강관", "Φ1200m/m, 3.2t", "M", 22, 273370),
("b08:cp_800", "파형강관이음관", "Φ800m/m", "개", 22, 21200),
("b08:cp_1000", "파형강관이음관", "Φ1000m/m", "개", 12, 32200),
("b08:cp_1200", "파형강관이음관", "Φ1200m/m", "개", 3, 40200),
]
MATERIALS = [
{"key": k, "name": n, "spec": s, "unit": u, "origin": "B08", "prices": {"관급": p}, "qty": q}
for k, n, s, u, q, p in OWNER
] + [
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
"prices": {"물가정보": 30000}, "qty": 16},
{"key": "b08:rubble", "name": "골재", "spec": "막자갈", "unit": "㎥", "origin": "B08",
"prices": {}, "qty": 5},
] # fmt: skip
ITEMS = [
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
"ref": {"tab": "dg_list", "key": "B00194"}},
{"key": "earth:2", "section": "1.1", "name": "규준틀설치 횡단규준틀", "unit": "개소", "qty": 28,
"ref": {"tab": "dg_list", "key": "B00467"}},
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
"ref": {"tab": "sg_list", "key": "D01341"}},
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
"ref": None,
"why": "조합 밖 토공 산근 — 연결 없음"},
] # fmt: skip
HOPYO = {
"B00194": {"labor": 21936, "material": 3885, "expense": 0}, # 일위대가목록표 F8~H8
"B00467": {"labor": 33032, "material": 6942, "expense": 0},
"D01341": {"labor": 1116, "material": 360, "expense": 463}, # 단가산출근거목록표 16줄
}
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
USER = {
"materials": {**{k: {"supply": "관급"} for k, *_ in OWNER}, "b08:sand": {"supply": "별도"},
"b08:rubble": {"supply": "별도"}},
"estimate": {"procurement_fee_pct": 0.54},
} # fmt: skip
def _run_ts(inputs, user):
files = ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
source = "".join(
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) for f in files
)
body = (
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeEstimation(i, u)));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", source + 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 _rows(sheets):
return {row["key"]: row for row in sheets["bill"]["rows"]}
def test_호표_단가_줄_엑셀_금액():
rows = _rows(compute_estimation(INPUTS, USER))
row = rows["earth:1"]
assert (row["price_sum"], row["labor"], row["material"], row["expense"], row["sum"]) == (
25821, 307104, 54390, 0, 361494,
) # fmt: skip # 설계내역서 F9 · I9 · K9 · M9 · G9
assert rows["earth:2"]["sum"] == 1119272 # G10
row = rows["earth:4"]
assert (row["labor"], row["material"], row["expense"], row["sum"]) == (
6333300, 2043000, 2627525, 11003825,
) # fmt: skip # 설계내역서 I14 · K14 · M14 · G14
assert rows["earth:5"]["sum"] is None and rows["earth:5"]["qty"] == 972 # 단가 연결 없음
def test_관급자재대_조달수수료_엑셀():
sheets = compute_estimation(INPUTS, USER)
rows = _rows(sheets)
assert rows["mat:b08:pipe_800"]["material"] == 33284000 # K247
assert rows["mat:b08:remicon_25_18_80"]["section"] == "G"
fee = rows["fee"]
assert (fee["qty"], fee["price_material"], fee["material"]) == (0.54, 69474220, 375160) # 253줄
assert rows["sec:G"]["material"] == 69849380 # K245 관급자재대
assert "(제외금액)" in rows["sec:G"]["note"]
owner = sheets["bill"]["totals"]["owner"]
assert owner == {"net_material": 69474220, "fee": 375160, "total": 69849380}
# 「별도」 = 4 자재대 · 단가 없는 별도 자재 = 빈 줄
assert rows["mat:b08:sand"]["section"] == "4" and rows["mat:b08:sand"]["material"] == 480000
assert rows["mat:b08:rubble"]["sum"] is None and rows["sec:4"]["blank"] == 1
def test_제목_합_빈_줄과_원가계산서_이음():
sheets = compute_estimation(INPUTS, USER)
rows = _rows(sheets)
keys = [row["key"] for row in sheets["bill"]["rows"] if row["kind"] == "title"]
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
assert sheets["bill"]["sums"] == {
"material": None,
"labor": None,
"expense": None,
"supply_net": 69474220,
}
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
sums = compute_estimation(full, USER)["bill"]["sums"]
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
assert sums["expense"] == 2627525
def test_조달수수료_율_빈칸이면_빈칸():
user = {**USER, "estimate": {}}
sheets = compute_estimation(INPUTS, user)
fee = _rows(sheets)["fee"]
assert fee["material"] is None and fee["qty"] is None
assert ("bill", "fee") in {(g["tab"], g["key"]) for g in sheets["gaps"]}
assert sheets["bill"]["totals"]["owner"]["total"] == 69474220
def test_항목_짜기_B08_수량():
earthwork = {"summary": [
{"group": "흙깍기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", "value": 5675},
{"group": "흙깍기", "kind": "연암", "spec": "굴삭기 + 브레카", "unit": "㎥", "value": 972},
{"group": "토사운반", "kind": "", "spec": "BH+DT+BH", "unit": "㎥", "value": 120},
{"group": "토사운반", "kind": "", "spec": "19 TON B/D", "unit": "㎥", "value": 80},
{"group": "구조물터파기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥",
"value": None},
]} # fmt: skip
summary = {
"columns": [
{"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"],
"label": "m", "combos": ["기슭막이 찰"]},
{"id": "pipe:len|800", "group": ["관 공", "Φ800m/m", "관매설"], "label": "m"},
{"id": "pipe:cp|800", "group": ["관 공", "Φ800m/m", "커플링"], "label": "ea"},
{"id": "pipe:chute|찰붙임", "group": ["돌붙임", "찰붙임"], "label": "㎡"},
],
"total": {"pipe:revet|돌쌓기(찰)|2.0": 20, "pipe:len|800": 84, "pipe:cp|800": 9},
} # fmt: skip
materials = {"material": {"rows": [
{"key": "sand", "amount": 16}, {"key": "rebar_d13", "amount": 0.5},
{"key": "rebar_d16", "amount": 0.25}, {"key": "gravel", "amount": None},
]}} # fmt: skip
items = {i["key"]: i for i in bill_items({"earthwork": earthwork, "summary": summary,
"materials": materials})["items"]} # fmt: skip
assert items["earth:4"]["qty"] == 5675 and items["earth:4"]["name"] == "절토(토사)"
assert items["earth:5"]["qty"] == 972 and items["earth:6"]["qty"] is None # 경암 줄 없음
assert items["earth:13"]["qty"] == 80 and items["earth:14"]["qty"] == 120 # 도자 · 덤프
assert items["earth:7"]["qty"] is None # 구조물터파기 = B08 도 빈칸
assert items["earth:1"]["why"].startswith("준비공") and items["earth:4"]["ref"] is None
st = {k: v for k, v in items.items() if k.startswith("st:")}
assert [(v["section"], v["qty"]) for v in st.values()] == [
("2.1", 20),
("2.2", 84),
("2.3", None),
]
assert "st:pipe:cp|800" not in items # 커플링 = 관급 자재
assert items["st:pipe:revet|돌쌓기(찰)|2.0"]["ref"] == {
"tab": "dg_list",
"key": "pipe:revet|돌쌓기(찰)|2.0",
}
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
def test_거울_TS_가_같은_값():
for user in (USER, {**USER, "estimate": {}}, {}):
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
@@ -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"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음