Files
Aislo/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts
T
eomsangdonandClaude Opus 5.5 f87831533c feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)
설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:51:32 +09:00

233 lines
8.0 KiB
TypeScript

/* =============================================================================
* 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<string, string>(
((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]),
};
}