/* ============================================================================= * B09_Estimation_Calc_Base.ts * B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). * * ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_144_b09_base_calc.py`. * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ export type Num = number | null; export type Row = Record; export interface Gap { tab: string; key: string; name: string; why: string; } /** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */ const EPS = 1e-7; /** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */ export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const; /** 자재 줄 사용자 표시 */ export const SUPPLY = ["관급", "별도"] as const; function scaled(value: number, places: number, fn: (x: number) => number): number { const a = Math.abs(value); let out: number; if (places >= 0) { const scale = 10 ** places; out = fn(a * scale) / scale; } else { const unit = 10 ** -places; out = fn(a / unit) * unit; } return value < 0 ? -out : out; } /** 엑셀 ROUNDDOWN */ export const rounddown = (value: number, places = 0): number => scaled(value, places, (x) => Math.floor(x + EPS)); /** 엑셀 ROUNDUP */ export const roundup = (value: number, places = 0): number => scaled(value, places, (x) => Math.ceil(x - EPS)); /** 엑셀 ROUND(반 올림) */ export const roundHalf = (value: number, places = 0): number => scaled(value, places, (x) => Math.floor(x + 0.5 + EPS)); export function num(value: unknown): Num { return typeof value === "number" && Number.isFinite(value) ? value : null; } /** 합 — 하나라도 빈칸이면 빈칸 */ export function total(values: Num[]): Num { let out = 0; for (const v of values) { if (v === null) return null; out += v; } return out; } export const gap = (tab: string, key: string, name: string, why: string): Gap => ({ tab, key, name, why, }); export function driverHour(day: Num, factor: Row | null | undefined): Num { if (day === null || !factor || !factor.den) return null; return rounddown((day * factor.num) / factor.den, 0); } const head = (row: Row, no: number): Row => ({ no, key: row.key, name: row.name || "", spec: row.spec || "", source: row.source || "", }); function laborSheet(labor: Row[], gaps: Gap[]): Row[] { return labor.map((row, i) => { const price = num(row.price); if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음")); return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price }; }); } function expenseSheet(machines: Row[], gaps: Gap[]): Row[] { return machines.map((m, i) => { const price = num(m.price); if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음")); return { ...head(m, i + 1), unit: "천원", price }; }); } export function lowest(prices: Row): [Num, string | null] { let best: [Num, string | null] = [null, null]; for (const source of SOURCES) { const v = num(prices[source]); if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source]; } return best; } function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { return materials.map((m, i) => { const mine: Row = user[m.key] || {}; const prices: Row = m.prices || {}; const [low, lowFrom] = lowest(prices); let applied: Num; let appliedFrom: string | null; if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; else [applied, appliedFrom] = [low, lowFrom]; if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null; const pages: Row = m.pages || {}; return { ...head(m, i + 1), unit: m.unit || "", origin: m.origin || "", prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])), pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])), lowest: low, lowest_from: lowFrom, applied, applied_from: applied !== null ? appliedFrom : null, supply, }; }); } function materialSheet(compare: Row[]): Row[] { const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; return compare.map((row) => ({ ...Object.fromEntries(keep.map((k) => [k, row[k]])), price: row.applied, })); } function line( name: string, spec: string, qty: Num, unit: string, kind: string, price: Num, ref: Row | null, pct = false, ): Row { let amount: Num; if (price === null || qty === null) amount = null; else if (pct) amount = rounddown((price * qty) / 100, 1); else amount = rounddown(price * qty, 1); return { name, spec, qty, unit, kind, price, amount, ref }; } function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row { const lines: Row[] = []; const loss = num(m.loss); const coef = loss !== null ? loss / 10000 : null; const price = num(m.price); lines.push( line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }), ); const crew = num(m.crew); if (crew) { const code = String(m.crew_code || ""); const driver: Row = drivers[code] || {}; const hour: Num = driver.hour ?? null; if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음")); lines.push( line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }), ); } const lph = num(m.fuel_lph); if (lph) { const kind = String(m.fuel || ""); const fuel: Row = fuels[kind] || {}; const fprice: Num = fuel.applied ?? null; if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`)); const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, { tab: "compare", key: fuel.key || "", }); lines.push(fuelLine); const misc = num(m.misc_pct); if (misc) lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true)); } if (price === null || coef === null) gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음")); const sums: Record = {}; for (const part of ["labor", "material", "expense"]) { const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount)); sums[part] = s === null ? null : rounddown(s, 0); } return { key: m.key, name: m.name, spec: m.spec || "", unit: "시간", lines, labor: sums.labor, material: sums.material, expense: sums.expense, sum: total([sums.labor, sums.material, sums.expense]), }; } function basisSheet(inputs: Row, laborRows: Row[]): Row { const factor = inputs.driver_factor; const byCode: Row = {}; for (const row of laborRows) if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row; const codes: string[] = []; for (const m of inputs.machines || []) { const code = m.crew_code ? String(m.crew_code) : null; if (code && num(m.crew) && !codes.includes(code)) codes.push(code); } const drivers = codes.map((code) => { const row: Row = byCode[code] || {}; const day: Num = row.price ?? null; return { key: code, labor_key: row.key ?? null, name: row.name || `직종 ${code}`, day, factor: (factor || {}).text || "", hour: driverHour(day, factor), }; }); const fx = (inputs.fx || []).map((f: Row) => ({ key: f.key, name: f.name || "", unit: f.unit || "", value: num(f.value), date: f.date || "", })); return { fx, drivers }; } /** 144 탭 일곱 + 「연결 없음」 목록 */ export function computeBase(inputs: Row, user: Row): Row { const gaps: Gap[] = []; const laborRows = laborSheet(inputs.labor || [], gaps); const expense = expenseSheet(inputs.machines || [], gaps); const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); const basis = basisSheet(inputs, laborRows); const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d])); const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row])); const fuels: Row = {}; for (const [kind, key] of Object.entries(inputs.fuel_keys || {})) if ((key as string) in byKey) fuels[kind] = byKey[key as string]; const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps)); const machine = blocks.map((b: Row, i: number) => ({ no: i + 1, key: b.key, name: b.name, spec: b.spec, unit: b.unit, sum: b.sum, labor: b.labor, material: b.material, expense: b.expense, })); return { labor: laborRows, expense, basis, compare, material: materialSheet(compare), machine_blocks: blocks, machine, gaps, }; }