/* ============================================================================= * B09_Draft_Schedule_Calc.ts * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · * `test_264_2_b09_schedule_options.py`) — 식 설명은 그쪽 머리. * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 * 공기집계 = 착공일부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 * 옵션(264) = 착공일 · 기후 자료 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준 · * 중복 C 끝수 · 마지막 달 비작업(기본값 = 위 식 그대로) * ========================================================================== */ import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; /** 사용자 값 기본(264 패널 · 표 칸 같은 값) */ export const DEFAULTS: Record = { hours: 8, units: 1, crew: 10, prep_docs: 2, prep_setup: 2, tidy: 4, etc: 0, rate_haul: 0, rate_base: 100, }; /** 공휴일 빼기 갈래 — 이름에 든 글 */ export const HOLIDAY_KINDS: Record = { substitute: ["대체"], election: ["선거"], labor: ["노동절", "근로자"], constitution: ["제헌절"], }; 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): number { const value = num(user[key]); return value === null || value < 0 ? DEFAULTS[key] : value; } function workRow(item: Row, unit: Row | undefined, c: Row, base: Row): Row { const hours: number = base.hours; const qty = num(item.qty) ?? 0; let rate = num(c.rate); if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? base.rate_haul : base.rate_base; const dailyIn = num(c.daily); let machine = unit?.machine as Row | null | undefined; const total = unit?.machine_sum as Row | null | undefined; if (machine && base.sum && total && (num(total.hr) ?? 0) > 0) { const many = (total.n || 1) > 1 ? ` 외 ${total.n - 1}종` : ""; machine = { ...machine, name: `${machine.name || ""}${many}`, hr: total.hr }; } 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) || base.units; 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) || base.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") || DEFAULTS.hours; const base: Row = { hours, units: cfg(user, "units") || DEFAULTS.units, crew: cfg(user, "crew") || DEFAULTS.crew, rate_haul: Math.min(cfg(user, "rate_haul"), 100), rate_base: Math.min(cfg(user, "rate_base"), 100), sum: user.machine_basis === "sum", }; 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] || {}, base)); } const docs = cfg(user, "prep_docs"); const setup = cfg(user, "prep_setup"); const etc = cfg(user, "etc"); const tidy = cfg(user, "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 counted(name: string, off: string[]): boolean { for (const part of name.split(";")) { const kinds = Object.keys(HOLIDAY_KINDS).filter((k) => HOLIDAY_KINDS[k].some((w) => part.includes(w)), ); if (!kinds.some((k) => off.includes(k))) return true; } return false; } /** 그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ function holidays( src: Row, year: number, month: number, first: number, user: Row, ): [number, string[]] { const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; const off: string[] = user.holiday_off || []; const named = new Map( ((src.holidays || {})[String(year)] || []).filter( ([d, n]: [string, string]) => d.startsWith(head) && Number(d.slice(-2)) >= first && counted(n, off), ), ); const rest = user.rest === "satsun" ? [6, 0] : [0]; let count = 0; for (let day = first; day <= monthDays(year, month); day += 1) { const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); if (rest.includes(weekday) || 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]; } /** 고른 기후 자료(원천 climates) · 없으면 원천 기본값 */ export const climateBase = (src: Row, user: Row): Row => (src.climates || {})[user.climate_set] || src.climate || {}; 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((climateBase(src, user)[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); let first = Math.min(Math.max(Math.trunc(Number(start.day) || 1), 1), monthDays(year, month)); const begin = Date.UTC(year, month - 1, first); 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 full = monthDays(year, month); const days = full - first + 1; const rain = climate(src, user, "rain", month); const heat = climate(src, user, "heat", month); const a = roundup(first === 1 ? rain + heat : ((rain + heat) * days) / full, 1); const [auto, names] = holidays(src, year, month, first, user); const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); const b = mine !== null ? mine : auto; let c = (a * b) / days; if (user.c_round === "round1") c = roundHalf(c, 1); 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 || user.last_off === "all" ? 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; first = 1; } const from = new Date(begin); const end = new Date(begin + (Math.max(total, 1) - 1) * 864e5); return { start: { year: from.getUTCFullYear(), month: from.getUTCMonth() + 1, day: from.getUTCDate() }, 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]), }; }