Files
Aislo/B09_Estimation/B09_Estimation_Calc_Bill.ts
T
eomsangdonandClaude Opus 5.5 489a682794 feat(B09): 설계내역서 — 공종 나무 엑셀 꼴 · B08 수량 · 호표 단가 · 관급 · 원가계산서 이음(145-3)
- 공종 나무 = 엑셀 꼴 고정(토공사 1.1~1.6 · 구조물공 2.1~2.3 · 운반공 · 자재대 · 관급자재대 제외금액) · 공구 안 나눔
- 항목 짜기(서버) = B08 토공집계표 짝 · 구조물 집계표 열(구조물군 → 공종 짝 · 커플링 뺌) · 운반공 수량 = B08 총괄자재
- 조합 밖 토공 · 운반 산근 · 준비공 = 빈칸 「연결 없음」 · 구조물 단가 = 대가 호표 자리(145-2 hopyo)
- 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 + 조달수수료(율 사용자 입력 · ROUNDDOWN 0)
- 셈 = 파이썬 · TS 짝 + 거울 · 금액 ROUNDDOWN(단가 × 수량, 0) 비목마다 · 제목 = 아는 금액 합 + 빈 줄 수
- 원가계산서 이음 = sheets.bill.sums {material · labor · expense · supply_net} · 빈 줄 있으면 null
- 시험 = test_145_3_b09_bill(엑셀 G9 361,494 · G14 11,003,825 · 관급 69,474,220 + 375,160 = 69,849,380)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
2026-10-09 15:13:05 +09:00

198 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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] };
}