/* ============================================================================= * B09_Estimation_UI_Tabs_Total.ts * B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`. * * 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바) * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · * 엑셀에 없는 현행 비목 = 줄만(빈칸) * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import type { Row } from "./B09_Estimation_Calc_Base"; import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total"; import { money, parseNumber, qty, text, type TabBuild, type TabContext, type TabTable, } from "./B09_Estimation_UI_Tabs"; type Num = number | null; const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US")); /** 손입력 칸 — 줄 키 → user.estimate 칸 */ const HAND: Record = { in_price: ["estimated_price", "추정가격(원)"], in_days: ["duration_days", "공사기간(일)"], safety: ["safety_base", "기초액(원)"], profit: ["profit_adjust", "이윤 보정(원 · 뺌)"], vat: ["vat_adjust", "부가세 조정(원)"], supply: ["procurement_fee_pct", "조달수수료율(%)"], indirect_material: ["indirect_material", "간접재료비(원)"], scrap: ["scrap", "작업설 부산물(원)"], }; const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); const ENV_NAME: Record = { road: "도로", other: "그 밖의 토목" }; /** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 자동 · 손입력 0) */ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void { const v = parseNumber(value); if (v === undefined) return; const estimate: Row = { ...(ctx.user.estimate || {}) }; if (column === "rate" && RATE_KEYS.has(rowKey)) { const rates: Row = { ...(estimate.rates || {}) }; if (v === null) delete rates[rowKey]; else rates[rowKey] = v; estimate.rates = rates; } else if (HAND[rowKey]) { const field = HAND[rowKey][0]; if (v === null) delete estimate[field]; else estimate[field] = v; } else return; ctx.setUser({ ...ctx.user, estimate }); } /** 칸 누름 — 환경 공종 줄 = 도로 ↔ 그 밖 · 사용자 요율 줄의 출처 칸 = 자동으로 되돌림 */ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: unknown): void { const estimate: Row = { ...(ctx.user.estimate || {}) }; if (rowKey === "in_env") { if (estimate.environment_kind === "road") delete estimate.environment_kind; else estimate.environment_kind = "road"; } else if (column === "candidate" && from === "user") { const rates: Row = { ...(estimate.rates || {}) }; delete rates[rowKey]; estimate.rates = rates; } else return; ctx.setUser({ ...ctx.user, estimate }); } function rateText(formula: string, rate: Num): string { return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); } export const buildTotal: TabBuild = (ctx): TabTable => { const t: Row = ctx.sheets.total || {}; const est: Row = ctx.user.estimate || {}; const picked: Row = t.rates || {}; const cand: Row = ctx.payload.rate_candidates || {}; const line: Row = t.lines || {}; const sub: Row = t.subtotals || {}; const rate = (key: string): Num => picked[key]?.rate ?? null; /** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */ const source = (key: string): string => { const r: Row = picked[key] || {}; if (r.from === "user") return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`; }; const hand = (key: string): Num => { const v = est[HAND[key][0]]; return typeof v === "number" ? v : null; }; const row = ( key: string, part: string, name: string, amount: Num, note = "", extra: Partial = {}, ): DataRow => ({ key, part, name, amount, note, rate: null, hand: null, candidate: "", ...extra, }); const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) => row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), { rate: rate(key), candidate: source(key), from: picked[key]?.from ?? null, rt, ...(HAND[key] ? { hand: hand(key) } : {}), }); const safety: Row = t.safety || {}; const supply: Row = t.supply || {}; const sums: Row = t.sums || {}; const env = est.environment_kind === "road" ? "road" : "other"; const rows: DataRow[] = [ row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", { hand: hand("in_price"), }), row( "in_days", "요율", "공사기간(일)", null, "간접노무비 · 기타경비 구간(직접공사비 = 순공사비계)", { hand: hand("in_days"), }, ), row( "in_env", "요율", `환경보전비 공종 = ${ENV_NAME[env]}`, null, `누르면 ${ENV_NAME[env === "road" ? "other" : "road"]}으로`, ), row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", { labor: sums.labor ?? null, material: sums.material ?? null, expense: sums.expense ?? null, }), ...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)), ...CURRENT_ONLY.map(([key, name, rt]) => row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", { candidate: cand[rt] ?? rt, }), ), row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null), rateRow(TAIL_LINES[0], RATE_LINES.length + 1), row("da", "다.", "소계", sub.da ?? null), rateRow(TAIL_LINES[1], RATE_LINES.length + 2), row("supply_price", "라.", "공급가액", sub.supply_price ?? null), rateRow(TAIL_LINES[2], RATE_LINES.length + 3), row("contract", "마.", "도급공사비", sub.contract ?? null), row( "supply", "", `${RATE_LINES.length + 4}. 관급자재대`, line.supply ?? null, `순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`, { hand: hand("supply") }, ), row("grand", "바.", "총공사비", sub.grand ?? null), ]; const safetyRow = rows.find((r) => r.key === "safety"); if (safetyRow && safety.a !== null && safety.a !== undefined) safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`; const profitRow = rows.find((r) => r.key === "profit"); if (profitRow && typeof t.profit_raw === "number") profitRow.note = `${profitRow.note} · 보정 전 ${won(t.profit_raw)}`; return { columns: [ text("part", "공종", 48), text("name", "명칭", 210), money("amount", "합계"), money("labor", "노무비"), money("material", "재료비"), money("expense", "경비"), qty("rate", "요율(%)", 5), money("hand", "수기 입력", undefined, 2), text("note", "산식", 320), text("candidate", "요율 출처", 260), ], rows, frozen: 2, hint: "요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " + "2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " + "수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · " + "공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || (key === "hand" && String(rows[i]?.key) in HAND), onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from), tip: (i, key) => { const r = rows[i]; if (!r?.rt || (key !== "rate" && key !== "candidate")) return null; const why = picked[String(r.key)]?.why; return [why ? `자동 근거 — ${why}` : "", cand[String(r.rt)] ?? r.rt] .filter(Boolean) .join(" · "); }, }; }; /** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ const COST_ROWS: Array<[string, string, string, string, string?]> = [ ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], ["indirect_material", "순공사원가", "재료비", "간접재료비"], ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], ["material", "순공사원가", "재료비", "소계", "direct"], ["direct_labor", "순공사원가", "노무비", "직접노무비", "direct"], ["indirect_labor", "순공사원가", "노무비", "간접노무비", "indirect_labor"], ["labor", "순공사원가", "노무비", "소계", "direct"], ["expense", "순공사원가", "경비", "산출경비", "direct"], ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map( ([key, name]) => [key, "순공사원가", "경비", name, key] as [string, string, string, string, string], ), ["expense_sum", "순공사원가", "경비", "소계", "net_cost"], ["overhead", "일반관리비", "", "", "overhead"], ["profit", "이윤", "", "", "profit"], ["supply_price", "총원가", "", "", "supply_price"], ["vat", "부가가치세", "", "", "vat"], ["contract", "도급공사비", "", "", "contract"], ["supply", "관급자재대", "", "", "supply"], ["grand", "총공사비", "", "", "grand"], ]; export const buildCost: TabBuild = (ctx): TabTable => { const t: Row = ctx.sheets.total || {}; const cost: Row = t.cost || {}; const share: Row = t.share || {}; const picked: Row = t.rates || {}; const formula: Record = Object.fromEntries( [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ key, rateText(f, picked[key]?.rate ?? null), ]), ); const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ key, group, part, name, amount: cost[key] ?? null, share: share[key] ?? null, note: formula[key] ?? "", link: link ?? "", })); return { columns: [ text("group", "구분", 96), text("part", "비목", 72), text("name", "세목", 180), money("amount", "금액"), qty("share", "구성비(%)", 2), text("note", "비고", 320), ], rows, frozen: 3, hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { const target = String(rows[i]?.link || ""); return target ? { tab: "total", key: target } : null; }, }; };