/* ============================================================================= * B09_Estimation_Calc_Bill.ts * B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임). * * ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨). * * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) * 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) * 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) · * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) * 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) · * 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 · * 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로) * ========================================================================== */ 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 = {}; 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[], qtyOf: Row | null = null): Row { let qty = num(item.qty); const over: Row = {}; // 166-2 수량 덮어쓰기 if (qtyOf && item.key in qtyOf && !item.hand) { over.qty_auto = qty; qty = num(qtyOf[item.key]); } 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, ...over, ...(item.hand ? { hand: true } : {}), ...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; const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"; const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"; /** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null { let tab = ref?.tab; let key = ref?.key; if (tab === "logic") { tab = "sg"; key = (books.sg_hand || {})[rowKey]; } if ((tab === "dg" || tab === "sg") && key) { const listing: Row[] = books[tab === "dg" ? "daega" : "sg_list"] || []; const row = listing.find((r) => r.key === key); if (!row) return null; const no = tab === "dg" ? `대가${row.no}` : `산근 ${row.no}호표`; const out: Row = { no, tab, key }; for (const p of [...PARTS, "sum"]) out[p] = row[p] ?? null; return out; } return key ? (hopyo[key] ?? null) : null; } /** 손 줄 → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> { const out: Array<[Row, Row | null, string]> = []; for (const hand of (user.bill || {}).rows || []) { const price = refPrice(hand.ref ?? null, books, hopyo, hand.key); const ref = price?.tab ? { tab: price.tab, key: price.key } : null; const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND; out.push([{ ...hand, hand: true, note: price?.no || "", ref }, price, why]); } return out; } /** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */ function groupRow(item: Row): Row { return { kind: "group", key: item.key, section: item.section, name: item.name || "", spec: item.spec || "", unit: item.unit || "", qty: null, note: "", ref: null, hand: true, sum: 0, labor: 0, material: 0, expense: 0, blank: 0, }; } /** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */ function nest(rows: Row[]): void { const groups = new Map(); for (const row of rows) if (row.kind === "group") groups.set(row.key, row); for (const row of rows) { if (!row.hand) continue; const up = groups.get(row.parent ?? ""); if (!up || up.section !== row.section) { row.parent = null; continue; } let at: Row | undefined = up; for (let n = 0; at && n <= groups.size; n += 1) { if (at.key === row.key) { row.parent = null; break; } at = groups.get(at.parent ?? ""); } } for (const row of rows) { const chain: Row[] = []; for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? "")) chain.push(at); const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1; row.level = row.kind === "title" ? depth : depth + 1 + chain.length; if (row.kind !== "item") continue; for (const g of chain) { for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0; if (row.sum === null) g.blank += 1; } } for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : ""; } /** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */ function place(items: Row[], hand: Row, after: string): void { let at = items.findIndex((row) => row.key === after); if (at >= 0) at += 1; else if (after === `sec:${hand.section}`) at = items.findIndex((r) => r.section === hand.section); items.splice(at < 0 ? items.length : at, 0, hand); } /** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> { const groups = new Map(); 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, books: Row | null = null, ): Row { const gaps: Gap[] = []; const hopyo: Row = inputs.hopyo || {}; const book: Row = books || {}; const qtyOf: Row = (user.bill || {}).qty || {}; 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, qtyOf)); continue; } const prices = refPrice(item.ref ?? null, book, hopyo); items.push(itemRow(item, prices, gaps, qtyOf)); } 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, qtyOf)); } for (const [item, prices, why] of handItems(user, book, hopyo)) { const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps); row.parent = item.parent ?? null; place(items, row, item.after || ""); } const owner = items.filter((row) => row.section === "G" && row.kind === "item"); 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 = {}; for (const [code] of SECTIONS) sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 }; for (const row of items) { if (row.kind !== "item") continue; 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)); } nest(rows); 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, books: Row | null = null, ): Row { const base = computeBase(inputs, user); const bill = computeBill(inputs, user, base, priceOf, books); return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] }; }