- 셈 짝(Py · TS) 새 옵션 — 착공일(그날부터 · A 날수 비례) · 기후 자료 고르기 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준(장비 시간 합) · 중복 C 끝수 · 마지막 달 비작업 - 원천 = 기후 셋(거창 25 · 울진 24 · 울진 소광 25 · 0에서 직접) · 단위당 장비 시간 합 - clean_schedule 새 칸(모르는 값 · 범위 밖 버림) - 시험 test_264_2 — 기본값 = 지금 결과 · 옵션마다 값 바뀜 · 거울 TS = Py Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, number> = {
|
|
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<string, string[]> = {
|
|
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<string, string>(
|
|
((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]),
|
|
};
|
|
}
|