From 378260b87ab4f0045b47fd21b5e7dde346887997 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:43:02 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(schedule):=20=E6=94=B9=E7=82=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=97=A5=E6=8E=92=E7=A8=8B=E4=B8=A6=E7=94=A2?= =?UTF-8?q?=E5=87=BA=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)); +} From e98d1d0cbea4107008a8d63a0d961b73581c5fe4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 22 Sep 2026 14:43:02 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(schedule):=20=E8=A3=9C=E4=B8=8A?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=97=A5=E6=8E=92=E7=A8=8B=E8=88=87=20CPM=20?= =?UTF-8?q?PERT=20=E8=A6=86=E8=93=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆蓋週末、年度假日、日曆 fallback、三點估算、關鍵路徑與循環相依。 --- test/schedule.test.js | 125 ++++++++++++++++++++++++++++++++---------- 1 file changed, 95 insertions(+), 30 deletions(-) diff --git a/test/schedule.test.js b/test/schedule.test.js index fbbec8d..75e9388 100644 --- a/test/schedule.test.js +++ b/test/schedule.test.js @@ -1,6 +1,6 @@ /** - * 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。 - * 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。 + * 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。 + * 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。 */ import test from 'node:test'; import assert from 'node:assert/strict'; @@ -8,6 +8,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { runScript, tmpRoot } from './helpers/run-script.js'; +/** 測試用的 2026 年日曆:12 月 25 日是已知假日,其他日期只由週末判斷。 */ +const CALENDAR = `data:text/csv,${encodeURIComponent( + '西元日期,星期,是否放假,備註\n20261224,四,0,\n20261225,五,2,測試假日\n20261228,一,0,', +)}`; + /** 把一份排程計畫寫成暫存檔,回傳路徑 */ function writePlan(plan) { mkdirSync(tmpRoot, { recursive: true }); @@ -17,14 +22,17 @@ function writePlan(plan) { return path; } -const run = (plan, args = []) => - runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]); +const run = (plan, args = [], opts = {}) => + runScript('schedule.js', ['--plan-file', writePlan(plan), ...args], { + ...opts, + env: { TEA_SDLC_CALENDAR_URL: CALENDAR, ...opts.env }, + }); /** 依 index 取出算出來的截止日 */ const dueOf = (json) => Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate])); -test('沒有相依時,每顆各自從起始日加上自己的人天', async () => { +test('沒有相依時,每顆各自從起始日加上工作日人天', async () => { const { code, json } = await run({ startDate: '2026-09-21', workPackages: [ @@ -34,10 +42,10 @@ test('沒有相依時,每顆各自從起始日加上自己的人天', async () }); assert.equal(code, 0); - assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' }); + assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' }); }); -test('有先決時,截止日從先決的截止日往後算', async () => { +test('有先決時,截止日從先決的工作日截止日往後算', async () => { const { json } = await run({ startDate: '2026-09-21', workPackages: [ @@ -46,7 +54,7 @@ test('有先決時,截止日從先決的截止日往後算', async () => { ], }); - assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-26' }); + assert.deepEqual(dueOf(json), { 1: '2026-09-23', 2: '2026-09-28' }); }); test('多個先決時取最晚的那一個當起點', async () => { @@ -59,28 +67,31 @@ test('多個先決時取最晚的那一個當起點', async () => { ], }); - assert.equal(dueOf(json)[3], '2026-10-01', '要等最晚的乙做完才開始'); + assert.equal(dueOf(json)[3], '2026-10-05', '要等最晚的乙做完才開始'); }); -test('任一工作包的截止日都不早於它的先決', async () => { +test('週末與年度假日都不會被算作工作日', async () => { const { json } = await run({ - startDate: '2026-09-21', - workPackages: [ - { index: 1, title: '甲', days: 3 }, - { index: 2, title: '乙', days: 1, depends: [1] }, - { index: 3, title: '丙', days: 1, depends: [2] }, - { index: 4, title: '丁', days: 4, depends: [1] }, - { index: 5, title: '戊', days: 2, depends: [3, 4] }, - ], + startDate: '2026-12-24', + workPackages: [{ index: 1, title: '跨假日', days: 2 }], }); - const due = dueOf(json); - const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] }; - for (const [index, prerequisites] of Object.entries(depends)) { - for (const p of prerequisites) { - assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`); - } - } + assert.equal(dueOf(json)[1], '2026-12-29'); +}); + +test('日曆抓不到時只跳過週末並印出警告', async () => { + const { code, json, stderr } = await run( + { + startDate: '2026-12-24', + workPackages: [{ index: 1, title: 'fallback', days: 2 }], + }, + [], + { env: { TEA_SDLC_CALENDAR_URL: 'http://127.0.0.1:1/unavailable.csv' } }, + ); + + assert.equal(code, 0); + assert.equal(dueOf(json)[1], '2026-12-28'); + assert.match(stderr, /警告.*週末/); }); test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => { @@ -96,6 +107,54 @@ test('輸出的順序即拓撲順序,先決一定排在後續之前', async () assert.deepEqual(json.data.order, [1, 2, 3]); }); +test('CPM 會計算 ES、EF、LS、LF、浮時與關鍵路徑', async () => { + const { json } = await run({ + startDate: '2026-09-21', + workPackages: [ + { index: 1, title: '長支線', days: 2 }, + { index: 2, title: '短支線', days: 1 }, + { index: 3, title: '匯合', days: 1, depends: [1, 2] }, + ], + }); + + const byIndex = Object.fromEntries(json.data.schedule.map((item) => [item.index, item])); + assert.deepEqual( + { ES: byIndex[2].ES, EF: byIndex[2].EF, LS: byIndex[2].LS, LF: byIndex[2].LF, float: byIndex[2].float }, + { ES: '2026-09-21', EF: '2026-09-22', LS: '2026-09-22', LF: '2026-09-23', float: 1 }, + ); + assert.deepEqual(json.data.criticalPath, [1, 3]); + assert.equal(byIndex[1].critical, true); + assert.equal(byIndex[2].critical, false); +}); + +test('PERT 會計算工作包與專案的期望值、變異數和標準差', async () => { + const { json } = await run({ + startDate: '2026-09-21', + workPackages: [ + { + index: 1, + title: '三點估算', + optimistic: 2, + mostLikely: 3, + pessimistic: 4, + }, + ], + }); + + const item = json.data.schedule[0]; + assert.deepEqual(item.pert, { + optimistic: 2, + mostLikely: 3, + pessimistic: 4, + expected: 3, + variance: 1 / 9, + }); + assert.equal(item.expectedDays, 3); + assert.equal(json.data.project.expectedDuration, 3); + assert.equal(json.data.project.variance, 1 / 9); + assert.equal(json.data.project.standardDeviation, 1 / 3); +}); + test('相依成環時中止,並指出環上的成員', async () => { const { code, json } = await run({ startDate: '2026-09-21', @@ -161,7 +220,7 @@ test('計畫檔不是合法 JSON 時中止', async () => { assert.equal(json.error.code, 'BAD_PLAN'); }); -test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => { +test('輸出保留標題、人天與日期欄位', async () => { const { json } = await run({ startDate: '2026-09-21', workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }], @@ -172,13 +231,20 @@ test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async title: '建立抽取契約', days: 3, dueDate: '2026-09-24', + ES: '2026-09-21', + EF: '2026-09-24', + LS: '2026-09-21', + LF: '2026-09-24', + float: 0, + critical: true, + expectedDays: 3, }); }); test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => { - const { code } = await runScript( - 'schedule.js', - ['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })], + const { code } = await run( + { startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] }, + [], { env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } }, ); @@ -186,7 +252,6 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => }); test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => { - // 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯 const { code, json } = await run({ startDate: '2026-09-21', workPackages: [