/* ============================================================================= * B09_Estimation_Calc_Base.ts * B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림). * * ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 · * 거울 시험 `resources/tester/test_144_b09_base_calc.py`. * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * * 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 inputs.labor · machines 로 줌) 나열 · * 경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · 값 = M02 단가산출 그대로) — 빈 표 * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · * 관급 · 별도 표시 → 재료비목록표 * 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력 * material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열) * 빈칸 = 원천이 없으면 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; const MACHINE_PARTS = ["labor", "material", "expense"] 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, }); 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 }; }); } 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 || {}), 견적: mine.quote }; const pages: Row = { ...(m.pages || {}), 견적: mine.quote_page }; const [low, lowFrom] = lowest(prices); const pick = (SOURCES as readonly string[]).includes(mine.pick) ? mine.pick : null; let applied: Num; let appliedFrom: string | null; if (pick) [applied, appliedFrom] = [num(prices[pick]), pick]; else 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 = mine.supply === "사급" // 841-1 사용자가 기본 표시(M02 자재표시)를 끔 ? null : ([mine.supply, m.supply].find((v) => (SUPPLY as readonly string[]).includes(v)) ?? null); 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, pick, supply, qty: num(m.qty), }; }); } function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] { const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; return compare.map((row) => { const price: Num = row.applied; const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]); return { ...Object.fromEntries(keep.map((k) => [k, row[k]])), qty: q, price, amount: price === null || q === null ? null : roundHalf(q * price, 0), }; }); } /** 144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표(짝 `compute_base`) */ export function computeBase(inputs: Row, user: Row): Row { const gaps: Gap[] = []; const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps); const machine = (inputs.machines || []).map((m: Row, i: number) => { const parts: Row = Object.fromEntries(MACHINE_PARTS.map((p) => [p, num(m[p])])); return { ...head(m, i + 1), unit: "시간", ...parts, sum: total(MACHINE_PARTS.map((p) => parts[p])), }; }); return { labor: laborSheet(inputs.labor || [], gaps), expense: [], basis: { fx: [], drivers: [] }, compare, material: materialSheet(compare, inputs.material_qty), machine_blocks: [], machine, gaps, }; }