373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 依工作日與相依關係計算工作包的日期、CPM 與 PERT。
|
||
*
|
||
* 政府辦公日曆只影響日期,不影響人天估算;取不到某年度資料時仍會跳過週末,
|
||
* 並把警告寫到 stderr。輸出的日期採結束日不含在工期內,延續原本 dueDate 契約。
|
||
*
|
||
* 用法:node scripts/schedule.js --plan-file <計畫檔>
|
||
*
|
||
* 計畫檔格式:
|
||
* {
|
||
* "startDate": "2026-09-22",
|
||
* "workPackages": [
|
||
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] },
|
||
* {
|
||
* "index": 13, "title": "驗證", "optimistic": 2,
|
||
* "mostLikely": 3, "pessimistic": 5, "depends": [12]
|
||
* }
|
||
* ]
|
||
* }
|
||
*/
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { ScriptError, main, parseFlags } from './lib.js';
|
||
|
||
const CALENDAR_METADATA_URL = 'https://data.gov.tw/api/front/dataset/detail?nid=14718';
|
||
const CALENDAR_URL_OVERRIDE = 'TEA_SDLC_CALENDAR_URL';
|
||
const calendarCache = new Map();
|
||
|
||
main(async () => {
|
||
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
|
||
const plan = readPlan(flags['plan-file']);
|
||
const order = topologicalOrder(plan.workPackages);
|
||
const estimates = new Map(plan.workPackages.map((wp) => [wp.index, pertFor(wp)]));
|
||
const holidays = await loadHolidays(plan, order, estimates);
|
||
const schedule = buildSchedule(plan, order, estimates, holidays);
|
||
|
||
return {
|
||
startDate: plan.startDate,
|
||
order,
|
||
schedule: schedule.items,
|
||
criticalPath: schedule.criticalPath,
|
||
project: schedule.project,
|
||
};
|
||
});
|
||
|
||
function readPlan(planFile) {
|
||
if (!existsSync(planFile)) {
|
||
throw new ScriptError('PLAN_FILE_MISSING', `找不到 --plan-file 指定的檔案 ${planFile}`);
|
||
}
|
||
|
||
let plan;
|
||
try {
|
||
plan = JSON.parse(readFileSync(planFile, 'utf8'));
|
||
} catch (error) {
|
||
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`);
|
||
}
|
||
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '') || !validDate(plan.startDate)) {
|
||
throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
|
||
}
|
||
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
|
||
throw new ScriptError('BAD_PLAN', 'workPackages 需為非空陣列');
|
||
}
|
||
|
||
const seen = new Set();
|
||
for (const wp of plan.workPackages) {
|
||
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者。
|
||
if (seen.has(wp.index)) {
|
||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
|
||
}
|
||
seen.add(wp.index);
|
||
|
||
if (!Number.isInteger(wp.index) || wp.index <= 0) {
|
||
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
|
||
}
|
||
if (
|
||
wp.depends !== undefined &&
|
||
(!Array.isArray(wp.depends) || wp.depends.some((dep) => !Number.isInteger(dep)))
|
||
) {
|
||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 depends 需為整數陣列`);
|
||
}
|
||
const estimate = pertFor(wp);
|
||
if (estimate === null && (!Number.isFinite(wp.days) || wp.days <= 0)) {
|
||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
|
||
}
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
function pertFor(wp) {
|
||
const source = wp.pert ?? wp.estimates ?? wp;
|
||
const names = [
|
||
['optimistic', 'mostLikely', 'pessimistic'],
|
||
['optimisticDays', 'mostLikelyDays', 'pessimisticDays'],
|
||
['o', 'm', 'p'],
|
||
];
|
||
const fields = names.find(([o, m, p]) => [o, m, p].some((name) => source[name] !== undefined));
|
||
if (!fields) return null;
|
||
|
||
const [optimistic, mostLikely, pessimistic] = fields.map((name) => Number(source[name]));
|
||
if (
|
||
![optimistic, mostLikely, pessimistic].every((value) => Number.isFinite(value) && value > 0) ||
|
||
optimistic > mostLikely ||
|
||
mostLikely > pessimistic
|
||
) {
|
||
throw new ScriptError(
|
||
'BAD_PLAN',
|
||
`工作包 #${wp.index} 的 PERT 估算需為正數且符合 optimistic ≤ mostLikely ≤ pessimistic`,
|
||
);
|
||
}
|
||
const expected = (optimistic + 4 * mostLikely + pessimistic) / 6;
|
||
const variance = ((pessimistic - optimistic) / 6) ** 2;
|
||
return { optimistic, mostLikely, pessimistic, expected, variance };
|
||
}
|
||
|
||
/**
|
||
* 拓撲排序:先決一定排在後續之前。排不完就代表有環。
|
||
*/
|
||
function topologicalOrder(workPackages) {
|
||
const known = new Set(workPackages.map((wp) => wp.index));
|
||
const remaining = new Map();
|
||
|
||
for (const wp of workPackages) {
|
||
for (const dep of wp.depends ?? []) {
|
||
if (!known.has(dep)) {
|
||
throw new ScriptError(
|
||
'UNKNOWN_DEPENDENCY',
|
||
`工作包 #${wp.index} 的先決 #${dep} 不在這份計畫裡`,
|
||
);
|
||
}
|
||
}
|
||
remaining.set(wp.index, new Set(wp.depends ?? []));
|
||
}
|
||
|
||
const order = [];
|
||
while (remaining.size > 0) {
|
||
const ready = [...remaining.entries()]
|
||
.filter(([, deps]) => deps.size === 0)
|
||
.map(([index]) => index)
|
||
.sort((a, b) => a - b);
|
||
|
||
if (ready.length === 0) {
|
||
throw new ScriptError(
|
||
'CYCLE_DETECTED',
|
||
`相依關係成環,這些工作包彼此卡住:${[...remaining.keys()].sort((a, b) => a - b).join('、')}`,
|
||
);
|
||
}
|
||
|
||
for (const index of ready) {
|
||
order.push(index);
|
||
remaining.delete(index);
|
||
}
|
||
for (const deps of remaining.values()) {
|
||
for (const index of ready) deps.delete(index);
|
||
}
|
||
}
|
||
return order;
|
||
}
|
||
|
||
async function loadHolidays(plan, order, estimates) {
|
||
const totalDays = order.reduce((total, index) => {
|
||
const wp = plan.workPackages.find((item) => item.index === index);
|
||
return total + Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||
}, 0);
|
||
const lastDate = addCalendarDays(plan.startDate, totalDays + 366);
|
||
const years = [];
|
||
for (let year = yearOf(plan.startDate); year <= yearOf(lastDate); year += 1) years.push(year);
|
||
|
||
const holidays = new Map();
|
||
for (const year of years) holidays.set(year, await holidaysForYear(year));
|
||
return holidays;
|
||
}
|
||
|
||
async function holidaysForYear(year) {
|
||
if (calendarCache.has(year)) return calendarCache.get(year);
|
||
|
||
try {
|
||
const url = process.env[CALENDAR_URL_OVERRIDE]?.trim() || (await calendarResourceUrl(year));
|
||
const response = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const holidays = parseCalendar(await response.text(), year);
|
||
calendarCache.set(year, holidays);
|
||
return holidays;
|
||
} catch (error) {
|
||
const fallback = new Set();
|
||
calendarCache.set(year, fallback);
|
||
console.error(`警告:無法取得 ${year} 年台灣辦公日曆(${error.message}),改以週末作為非工作日`);
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
async function calendarResourceUrl(year) {
|
||
const response = await fetch(CALENDAR_METADATA_URL, { signal: AbortSignal.timeout(10_000) });
|
||
if (!response.ok) throw new Error(`日曆中繼資料 HTTP ${response.status}`);
|
||
const body = await response.json();
|
||
const rocYear = year - 1911;
|
||
const resources = body.payload?.resources ?? [];
|
||
const resource = [...resources].reverse().find(
|
||
(item) =>
|
||
item.file_format?.toUpperCase() === 'CSV' &&
|
||
item.description?.startsWith(`${rocYear}年`) &&
|
||
item.description.includes('政府行政機關辦公日曆表') &&
|
||
!item.description.includes('Google'),
|
||
);
|
||
if (!resource?.url) throw new Error(`資料集沒有 ${year} 年 CSV`);
|
||
return resource.url;
|
||
}
|
||
|
||
function parseCalendar(text, year) {
|
||
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() !== '');
|
||
if (lines.length < 2) throw new Error('日曆 CSV 沒有資料');
|
||
const header = parseCsvLine(lines[0]).map((field) => field.trim());
|
||
const dateColumn = header.indexOf('西元日期');
|
||
const holidayColumn = header.indexOf('是否放假');
|
||
if (dateColumn < 0 || holidayColumn < 0) throw new Error('日曆 CSV 缺少必要欄位');
|
||
|
||
const holidays = new Set();
|
||
for (const line of lines.slice(1)) {
|
||
const fields = parseCsvLine(line);
|
||
const date = fields[dateColumn]?.trim();
|
||
const holiday = fields[holidayColumn]?.trim();
|
||
if (/^\d{8}$/.test(date) && Number(date.slice(0, 4)) === year && holiday !== '0') {
|
||
holidays.add(`${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`);
|
||
}
|
||
}
|
||
return holidays;
|
||
}
|
||
|
||
function parseCsvLine(line) {
|
||
const fields = [];
|
||
let field = '';
|
||
let quoted = false;
|
||
for (let i = 0; i < line.length; i += 1) {
|
||
const char = line[i];
|
||
if (char === '"' && line[i + 1] === '"' && quoted) {
|
||
field += '"';
|
||
i += 1;
|
||
} else if (char === '"') {
|
||
quoted = !quoted;
|
||
} else if (char === ',' && !quoted) {
|
||
fields.push(field);
|
||
field = '';
|
||
} else {
|
||
field += char;
|
||
}
|
||
}
|
||
fields.push(field);
|
||
return fields;
|
||
}
|
||
|
||
function buildSchedule(plan, order, estimates, holidays) {
|
||
const byIndex = new Map(plan.workPackages.map((wp) => [wp.index, wp]));
|
||
const early = new Map();
|
||
for (const index of order) {
|
||
const wp = byIndex.get(index);
|
||
const duration = Math.ceil(estimates.get(index)?.expected ?? wp.days);
|
||
const ES = (wp.depends ?? []).reduce(
|
||
(latest, dep) => (early.get(dep).EF > latest ? early.get(dep).EF : latest),
|
||
plan.startDate,
|
||
);
|
||
const EF = addWorkdays(ES, duration, holidays);
|
||
early.set(index, { ES, EF, duration });
|
||
}
|
||
|
||
const projectFinish = [...early.values()].reduce(
|
||
(latest, item) => (item.EF > latest ? item.EF : latest),
|
||
plan.startDate,
|
||
);
|
||
const successors = new Map(order.map((index) => [index, []]));
|
||
for (const wp of plan.workPackages) {
|
||
for (const dep of wp.depends ?? []) successors.get(dep).push(wp.index);
|
||
}
|
||
|
||
const late = new Map();
|
||
for (const index of [...order].reverse()) {
|
||
const { duration } = early.get(index);
|
||
const successorStarts = successors.get(index).map((successor) => late.get(successor).LS);
|
||
const LF = successorStarts.length === 0 ? projectFinish : successorStarts.reduce(minDate);
|
||
const LS = subtractWorkdays(LF, duration, holidays);
|
||
late.set(index, { LS, LF });
|
||
}
|
||
|
||
const items = order.map((index) => {
|
||
const wp = byIndex.get(index);
|
||
const estimate = estimates.get(index);
|
||
const { ES, EF, duration } = early.get(index);
|
||
const { LS, LF } = late.get(index);
|
||
const float = workdayDistance(ES, LS, holidays);
|
||
return {
|
||
index,
|
||
title: wp.title,
|
||
days: wp.days,
|
||
dueDate: EF,
|
||
ES,
|
||
EF,
|
||
LS,
|
||
LF,
|
||
float,
|
||
critical: float === 0,
|
||
expectedDays: estimate?.expected ?? wp.days,
|
||
...(estimate ? { pert: estimate } : {}),
|
||
};
|
||
});
|
||
const criticalPath = items.filter((item) => item.critical).map((item) => item.index);
|
||
const variance = items.reduce((total, item) => total + (item.pert?.variance ?? 0), 0);
|
||
const expectedDuration = items.reduce((total, item) => total + (item.pert?.expected ?? item.days), 0);
|
||
return {
|
||
items,
|
||
criticalPath,
|
||
project: {
|
||
finishDate: projectFinish,
|
||
expectedDuration,
|
||
variance,
|
||
standardDeviation: Math.sqrt(variance),
|
||
},
|
||
};
|
||
}
|
||
|
||
function addWorkdays(date, days, holidays) {
|
||
let current = date;
|
||
let remaining = days;
|
||
while (remaining > 0) {
|
||
current = addCalendarDays(current, 1);
|
||
if (isWorkday(current, holidays)) remaining -= 1;
|
||
}
|
||
return current;
|
||
}
|
||
|
||
function subtractWorkdays(date, days, holidays) {
|
||
let current = date;
|
||
let remaining = days;
|
||
while (remaining > 0) {
|
||
current = addCalendarDays(current, -1);
|
||
if (isWorkday(current, holidays)) remaining -= 1;
|
||
}
|
||
return current;
|
||
}
|
||
|
||
function workdayDistance(start, end, holidays) {
|
||
if (start === end) return 0;
|
||
let current = start;
|
||
let distance = 0;
|
||
while (current < end) {
|
||
current = addCalendarDays(current, 1);
|
||
if (isWorkday(current, holidays)) distance += 1;
|
||
}
|
||
return distance;
|
||
}
|
||
|
||
function isWorkday(date, holidays) {
|
||
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
|
||
return day !== 0 && day !== 6 && !holidays.get(yearOf(date))?.has(date);
|
||
}
|
||
|
||
function minDate(a, b) {
|
||
return a < b ? a : b;
|
||
}
|
||
|
||
function addCalendarDays(date, days) {
|
||
const moment = new Date(`${date}T00:00:00Z`);
|
||
moment.setUTCDate(moment.getUTCDate() + days);
|
||
return moment.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function validDate(date) {
|
||
const moment = new Date(`${date}T00:00:00Z`);
|
||
return !Number.isNaN(moment.valueOf()) && moment.toISOString().slice(0, 10) === date;
|
||
}
|
||
|
||
function yearOf(date) {
|
||
return Number(date.slice(0, 4));
|
||
}
|