保證一件事:任一工作包的截止日都不早於它的先決。人工排時程最常出現的矛盾就是 前置工作比後續還晚到期,看板上看起來合理、實際上做不到。 以 Kahn 演算法排序,排不完就代表有環,而環上的成員正是排不進去的那些,直接把 它們列出來——相依成環是拆法有問題,硬排沒有意義。 日期以日曆日累加,不跳週末也不扣假日:跳過哪些日子是團隊政策,這裡不替使用者 決定。這支腳本不碰 Gitea 也不碰 git,純算數字,因此沒有前置檢查也不需要登入。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
4.3 KiB
JavaScript
131 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 依相依關係推算每顆工作包的截止日。
|
||
*
|
||
* 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的
|
||
* 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。
|
||
*
|
||
* 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。
|
||
* 算出來的日期交給 issue-update 逐顆寫上去。
|
||
*
|
||
* 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策,
|
||
* 這裡不替使用者決定。
|
||
*
|
||
* 用法:node scripts/schedule.js --plan-file <計畫檔>
|
||
*
|
||
* 計畫檔格式:
|
||
* {
|
||
* "startDate": "2026-09-21",
|
||
* "workPackages": [
|
||
* { "index": 12, "title": "建立抽取契約", "days": 3, "depends": [11] }
|
||
* ]
|
||
* }
|
||
*/
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { ScriptError, main, parseFlags } from './lib.js';
|
||
|
||
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 = [];
|
||
|
||
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 };
|
||
});
|
||
|
||
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 ?? '')) {
|
||
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 需為非空陣列');
|
||
}
|
||
|
||
for (const wp of plan.workPackages) {
|
||
if (!Number.isInteger(wp.index) || wp.index <= 0) {
|
||
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`);
|
||
}
|
||
if (!Number.isFinite(wp.days) || wp.days <= 0) {
|
||
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
|
||
}
|
||
}
|
||
return plan;
|
||
}
|
||
|
||
/**
|
||
* 拓撲排序:先決一定排在後續之前。
|
||
* 用 Kahn 演算法——排不完就代表有環,而環上的成員正是排不進去的那些。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */
|
||
function addDays(date, days) {
|
||
const moment = new Date(`${date}T00:00:00Z`);
|
||
moment.setUTCDate(moment.getUTCDate() + days);
|
||
return moment.toISOString().slice(0, 10);
|
||
}
|