From 0f53567737d524ea294ffa17f59d8774f24dcce1 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 06:06:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E6=8E=92=E7=A8=8B):=20=E4=BE=9D=E7=9B=B8?= =?UTF-8?q?=E4=BE=9D=E9=97=9C=E4=BF=82=E6=8B=93=E6=92=B2=E6=8E=A8=E7=AE=97?= =?UTF-8?q?=E6=88=AA=E6=AD=A2=E6=97=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保證一件事:任一工作包的截止日都不早於它的先決。人工排時程最常出現的矛盾就是 前置工作比後續還晚到期,看板上看起來合理、實際上做不到。 以 Kahn 演算法排序,排不完就代表有環,而環上的成員正是排不進去的那些,直接把 它們列出來——相依成環是拆法有問題,硬排沒有意義。 日期以日曆日累加,不跳週末也不扣假日:跳過哪些日子是團隊政策,這裡不替使用者 決定。這支腳本不碰 Gitea 也不碰 git,純算數字,因此沒有前置檢查也不需要登入。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/schedule.js | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 scripts/schedule.js diff --git a/scripts/schedule.js b/scripts/schedule.js new file mode 100644 index 0000000..aa04c86 --- /dev/null +++ b/scripts/schedule.js @@ -0,0 +1,130 @@ +#!/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); +}