#!/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)); }