Files
Aislo/B09_Estimation/B09_Estimation_Calc_Bill.ts
T
eomsangdonandClaude Opus 5.5 3bd20530bf fix(B09): 내역 구조물 줄 ref = 대가 블록 — 집계표 역전개 · 이동 이음 · 한 바퀴 시험(145-2)
- daega_price / daegaPrice 에 블록 key · split_by_block(파이썬 · TS) = 대가 단가가 있으면 ref {tab: dg, key}
- 한 바퀴 시험 = 설계내역서 → 대가 → 산근(145-1) → 중기 → 집계표(145-5) · 연결 없음 0

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

239 lines
8.5 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,
roundHalf,
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;
}
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
export type PriceOf = (owner: string) => Row | null;
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
const groups = new Map<string, Row>();
for (const [owner, value] of Object.entries(item.owners || {})) {
const price = priceOf(owner);
const tag = price ? String(price.no || "") : "";
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
const group = groups.get(tag)!;
group.qty += num(value) || 0;
group.owners.push(owner);
}
const out: Array<[Row, Row | null]> = [];
for (const [tag, group] of groups) {
const part: Row = {
...item,
key: tag ? `${item.key}#${tag}` : item.key,
qty: roundHalf(group.qty, 3),
note: [tag, item.note || ""].filter((t) => t).join(" · "),
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
};
// 대가 블록(145-2) — 이동 · 집계표 역전개
if (group.price?.key) part.ref = { tab: "dg", key: group.price.key };
out.push([part, group.price]);
}
return out;
}
export function computeBill(
inputs: Row,
user: Row,
base: Row,
priceOf: PriceOf | null = null,
): Row {
const gaps: Gap[] = [];
const hopyo: Row = inputs.hopyo || {};
const items: Row[] = [];
for (const item of (inputs.bill || {}).items || []) {
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
for (const [part, prices] of splitByBlock(item, priceOf))
items.push(itemRow(part, prices, gaps));
continue;
}
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, priceOf: PriceOf | null = null): Row {
const base = computeBase(inputs, user);
const bill = computeBill(inputs, user, base, priceOf);
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
}