From 378260b87ab4f0045b47fd21b5e7dde346887997 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=E6=94=B9=E7=82=BA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=97=A5=E6=8E=92=E7=A8=8B=E4=B8=A6=E7=94=A2=E5=87=BA?= =?UTF-8?q?=20CPM=20=E8=88=87=20PERT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以政府辦公日曆排除年度假日,並由同一份計畫計算工作日日期、浮時、關鍵路徑與 PERT 統計。 --- scripts/schedule.js | 304 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 269 insertions(+), 35 deletions(-) diff --git a/scripts/schedule.js b/scripts/schedule.js index 5a0f4ba..b62545e 100644 --- a/scripts/schedule.js +++ b/scripts/schedule.js @@ -1,50 +1,46 @@ #!/usr/bin/env node /** - * 依相依關係推算每顆工作包的截止日。 + * 依工作日與相依關係計算工作包的日期、CPM 與 PERT。 * - * 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的 - * 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。 - * - * 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。 - * 算出來的日期交給 issue-update 逐顆寫上去。 - * - * 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策, - * 這裡不替使用者決定。 + * 政府辦公日曆只影響日期,不影響人天估算;取不到某年度資料時仍會跳過週末, + * 並把警告寫到 stderr。輸出的日期採結束日不含在工期內,延續原本 dueDate 契約。 * * 用法:node scripts/schedule.js --plan-file <計畫檔> * * 計畫檔格式: * { - * "startDate": "2026-09-21", + * "startDate": "2026-09-22", * "workPackages": [ - * { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] } + * { "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 dueByIndex = new Map(); - const schedule = []; + 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); - for (const index of order) { - const wp = plan.workPackages.find((item) => item.index === index); - // 從所有先決裡最晚的那一個接著做;沒有先決就從起始日開始 - const readyFrom = (wp.depends ?? []).reduce( - (latest, dep) => (dueByIndex.get(dep) > latest ? dueByIndex.get(dep) : latest), - plan.startDate, - ); - const dueDate = addDays(readyFrom, wp.days); - dueByIndex.set(index, dueDate); - schedule.push({ index, title: wp.title, days: wp.days, dueDate }); - } - - return { startDate: plan.startDate, order, schedule }; + return { + startDate: plan.startDate, + order, + schedule: schedule.items, + criticalPath: schedule.criticalPath, + project: schedule.project, + }; }); function readPlan(planFile) { @@ -59,7 +55,7 @@ function readPlan(planFile) { throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`); } - if (!/^\d{4}-\d{2}-\d{2}$/.test(plan.startDate ?? '')) { + 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) { @@ -68,8 +64,7 @@ function readPlan(planFile) { const seen = new Set(); for (const wp of plan.workPackages) { - // 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者, - // 算出來的時程會是兩份定義混出來的東西,而且完全不會報錯 + // 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者。 if (seen.has(wp.index)) { throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`); } @@ -78,16 +73,48 @@ function readPlan(planFile) { if (!Number.isInteger(wp.index) || wp.index <= 0) { throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`); } - if (!Number.isFinite(wp.days) || wp.days <= 0) { + 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 }; +} + /** - * 拓撲排序:先決一定排在後續之前。 - * 用 Kahn 演算法——排不完就代表有環,而環上的成員正是排不進去的那些。 + * 拓撲排序:先決一定排在後續之前。排不完就代表有環。 */ function topologicalOrder(workPackages) { const known = new Set(workPackages.map((wp) => wp.index)); @@ -130,9 +157,216 @@ function topologicalOrder(workPackages) { return order; } -/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */ -function addDays(date, days) { +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)); +}