Files
tea-sdlc/scripts/schedule.js
T
jiantw83 378260b87a feat(schedule): 改為工作日排程並產出 CPM 與 PERT
以政府辦公日曆排除年度假日,並由同一份計畫計算工作日日期、浮時、關鍵路徑與 PERT 統計。
2026-09-22 14:43:02 +08:00

373 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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));
}