/* ============================================================================= * B09_Draft_Schedule_Calc.ts * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리. * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 * 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 * ========================================================================== */ import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; const HOURS = 8; const CREW = 10; const PREP_DOCS = 2; const PREP_SETUP = 2; const TIDY = 4; const SKIP_TOP = ["4", "G"]; const OVERLAP_TOP = ["3"]; const MAX_MONTHS = 120; const top = (section: unknown): string => String(section ?? "").split(".")[0]; function cfg(user: Row, key: string, fallback: number): number { const value = num(user[key]); return value === null || value < 0 ? fallback : value; } function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { const qty = num(item.qty) ?? 0; let rate = num(c.rate); if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100; const dailyIn = num(c.daily); const machine = unit?.machine as Row | null | undefined; const labor = num(unit?.labor); const out: Row = { kind: "item", key: item.key, name: item.name || "", spec: item.spec || "", unit: item.unit || "", lv: item.level || 1, qty, rate, basis: "", who: "", per: null, put: null, q: null, daily: null, daily_in: dailyIn, days: null, }; let need: number; if (dailyIn !== null && dailyIn > 0) { Object.assign(out, { basis: "입력", daily: dailyIn }); need = (qty * rate) / 100 / dailyIn; } else if (machine && (num(machine.hr) ?? 0) > 0) { const hr = num(machine.hr) as number; const units = num(c.units) || 1; Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); need = (((qty * rate) / 100) * hr) / (hours * units); } else if (labor !== null && labor > 0) { const crew = num(c.crew) || CREW; Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); need = (((qty * rate) / 100) * labor) / crew; } else { out.basis = "연결 없음"; out.days = rate === 0 ? 0 : null; // 다 겹침 = 일작업량 없어도 0 return out; } out.days = roundup(need, 1); return out; } /** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ export function workTable(src: Row, billRows: Row[], user: Row): Row { const hours = cfg(user, "hours", HOURS) || HOURS; const units: Row = src.units || {}; const cfgs: Row = user.rows || {}; const rows: Row[] = []; let pending: Array<[Row, boolean]> = []; for (const row of billRows) { const level = row.level || 1; if (row.kind !== "item") { pending = pending.filter(([p]) => (p.level || 1) < level); pending.push([row, false]); continue; } const key = String(row.key ?? ""); const qty = num(row.qty); if ( SKIP_TOP.includes(top(row.section)) || key.startsWith("mat:") || key === "fee" || qty === null || qty <= 0 ) continue; for (const p of pending) { if (p[1]) continue; p[1] = true; rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); } rows.push(workRow(row, units[key], cfgs[key] || {}, hours)); } const docs = cfg(user, "prep_docs", PREP_DOCS); const setup = cfg(user, "prep_setup", PREP_SETUP); const etc = cfg(user, "etc", 0); const tidy = cfg(user, "tidy", TIDY); const items = rows.filter((r) => r.kind === "item"); const days = items.reduce((s: number, r) => s + (r.days || 0), 0); const subtotal = roundHalf(days + etc, 0); return { hours, rows, prep_docs: docs, prep_setup: setup, prep: docs + setup, etc, subtotal, tidy, total: roundHalf(docs + setup + subtotal + tidy, 0), missing: items.filter((r) => r.days === null).length, }; } const monthDays = (year: number, month: number): number => new Date(Date.UTC(year, month, 0)).getUTCDate(); const pad = (n: number): string => String(n).padStart(2, "0"); /** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ function holidays(src: Row, year: number, month: number): [number, string[]] { const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; const named = new Map( ((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)), ); let count = 0; for (let day = 1; day <= monthDays(year, month); day += 1) { const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0; if (sunday || named.has(`${head}${pad(day)}`)) count += 1; } const names = [...named.entries()] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([d, n]) => `${Number(d.slice(-2))}일 ${n}`); return [count, names]; } function climate(src: Row, user: Row, kind: string, month: number): number { const mine = num(((user.climate || {})[kind] || [])[month - 1]); if (mine !== null) return mine; return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0; } /** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */ export function monthTable(src: Row, user: Row, parts: number[]): Row { const start: Row = { ...(src.start || {}), ...(user.start || {}) }; let year = Math.trunc(Number(start.year) || 2000); let month = Math.trunc(Number(start.month) || 1); const left = parts.map((p) => Math.max(p, 0)); const months: Row[] = []; let total = 0; while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { if (months.length >= MAX_MONTHS) break; const days = monthDays(year, month); const rain = climate(src, user, "rain", month); const heat = climate(src, user, "heat", month); const a = roundup(rain + heat, 1); const [auto, names] = holidays(src, year, month); const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); const b = mine !== null ? mine : auto; const c = (a * b) / days; const off = rounddown(a + b - c, 0); const avail = days - off; const cap = Math.max(avail, 0); const use = [0, 0, 0]; let room = cap; for (let i = 0; i < 3; i += 1) { const take = Math.min(room, left[i]); use[i] = take; left[i] -= take; room -= take; } const used = use[0] + use[1] + use[2]; const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0); const span = used + offUsed; total += span; months.push({ year, month, days, rain, heat, a, b, b_auto: auto, names, c, off, avail, prep: use[0], work: use[1], tidy: use[2], used, off_used: offUsed, span, }); if (month === 12) { year += 1; month = 1; } else month += 1; } const first = months[0]; const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5); return { start: { year: first.year, month: first.month }, months, days: total, months_n: roundHalf(total / 30, 1), end: end.toISOString().slice(0, 10), }; } /** 공기 한 벌 — {work: 공기산출, calendar: 공기집계} */ export function computeSchedule(src: Row, billRows: Row[], user: Row): Row { const u = user || {}; const work = workTable(src || {}, billRows || [], u); return { work, calendar: monthTable(src || {}, u, [work.prep, work.subtotal, work.tidy]), }; }