- 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
239 lines
8.5 KiB
TypeScript
239 lines
8.5 KiB
TypeScript
/* =============================================================================
|
||
* 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] };
|
||
}
|