Merge pull request 'feat/workday-schedule-cpm/main' (#79) from feat/workday-schedule-cpm/main into master

Reviewed-on: #79
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
This commit was merged in pull request #79.
This commit is contained in:
2026-09-22 06:54:32 +00:00
2 changed files with 364 additions and 65 deletions
+269 -35
View File
@@ -1,50 +1,46 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* 依相依關係推算每顆工作包的截止日。 * 依工作日與相依關係計算工作包的日期、CPM 與 PERT。
* *
* 保證一件事:任一工作包的截止日都不早於它的先決工作包。人工排時程最常出現的 * 政府辦公日曆只影響日期,不影響人天估算;取不到某年度資料時仍會跳過週末,
* 矛盾就是前置工作比後續還晚到期,看板上看起來合理、實際上做不到。 * 並把警告寫到 stderr。輸出的日期採結束日不含在工期內,延續原本 dueDate 契約。
*
* 這支腳本不碰 Gitea 也不碰 git,純算數字,所以沒有前置檢查、也不需要登入。
* 算出來的日期交給 issue-update 逐顆寫上去。
*
* 日期以「日曆日」累加,不跳週末也不扣假日——跳過哪些日子是團隊政策,
* 這裡不替使用者決定。
* *
* 用法:node scripts/schedule.js --plan-file <計畫檔> * 用法:node scripts/schedule.js --plan-file <計畫檔>
* *
* 計畫檔格式: * 計畫檔格式:
* { * {
* "startDate": "2026-09-21", * "startDate": "2026-09-22",
* "workPackages": [ * "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 { existsSync, readFileSync } from 'node:fs';
import { ScriptError, main, parseFlags } from './lib.js'; 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 () => { main(async () => {
const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] }); const flags = parseFlags(process.argv.slice(2), { required: ['plan-file'] });
const plan = readPlan(flags['plan-file']); const plan = readPlan(flags['plan-file']);
const order = topologicalOrder(plan.workPackages); const order = topologicalOrder(plan.workPackages);
const dueByIndex = new Map(); const estimates = new Map(plan.workPackages.map((wp) => [wp.index, pertFor(wp)]));
const schedule = []; const holidays = await loadHolidays(plan, order, estimates);
const schedule = buildSchedule(plan, order, estimates, holidays);
for (const index of order) { return {
const wp = plan.workPackages.find((item) => item.index === index); startDate: plan.startDate,
// 從所有先決裡最晚的那一個接著做;沒有先決就從起始日開始 order,
const readyFrom = (wp.depends ?? []).reduce( schedule: schedule.items,
(latest, dep) => (dueByIndex.get(dep) > latest ? dueByIndex.get(dep) : latest), criticalPath: schedule.criticalPath,
plan.startDate, project: schedule.project,
); };
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) { function readPlan(planFile) {
@@ -59,7 +55,7 @@ function readPlan(planFile) {
throw new ScriptError('BAD_PLAN', `${planFile} 不是合法的 JSON:${error.message}`); 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}`); throw new ScriptError('BAD_PLAN', `startDate 需為 YYYY-MM-DD,收到的是 ${plan.startDate}`);
} }
if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) { if (!Array.isArray(plan.workPackages) || plan.workPackages.length === 0) {
@@ -68,8 +64,7 @@ function readPlan(planFile) {
const seen = new Set(); const seen = new Set();
for (const wp of plan.workPackages) { for (const wp of plan.workPackages) {
// 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者, // 同一個 index 出現兩次時,相依看的是後者、標題與人天卻取到前者。
// 算出來的時程會是兩份定義混出來的東西,而且完全不會報錯
if (seen.has(wp.index)) { if (seen.has(wp.index)) {
throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`); throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 在計畫裡出現了不只一次`);
} }
@@ -78,16 +73,48 @@ function readPlan(planFile) {
if (!Number.isInteger(wp.index) || wp.index <= 0) { if (!Number.isInteger(wp.index) || wp.index <= 0) {
throw new ScriptError('BAD_PLAN', `工作包的 index 需為正整數,收到的是 ${wp.index}`); 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}`); throw new ScriptError('BAD_PLAN', `工作包 #${wp.index} 的 days 需為正數,收到的是 ${wp.days}`);
} }
} }
return plan; 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) { function topologicalOrder(workPackages) {
const known = new Set(workPackages.map((wp) => wp.index)); const known = new Set(workPackages.map((wp) => wp.index));
@@ -130,9 +157,216 @@ function topologicalOrder(workPackages) {
return order; return order;
} }
/** 在 YYYY-MM-DD 上加幾個日曆日,回傳同樣格式 */ async function loadHolidays(plan, order, estimates) {
function addDays(date, days) { 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`); const moment = new Date(`${date}T00:00:00Z`);
moment.setUTCDate(moment.getUTCDate() + days); moment.setUTCDate(moment.getUTCDate() + days);
return moment.toISOString().slice(0, 10); 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));
}
+95 -30
View File
@@ -1,6 +1,6 @@
/** /**
* 截止日推算:依相依關係拓撲排序,任一工作包的截止日不得早於它的先決工作包。 * 排程計算:日期只落在工作日,並從同一份計畫產出 PERT 與 CPM。
* 這支腳本不碰 Gitea,純算數字,所以測試只餵檔案、只看 stdout。 * 腳本對外會抓政府日曆;測試用 data URL 固定資料,fallback 測試才改用失效網址。
*/ */
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -8,6 +8,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { runScript, tmpRoot } from './helpers/run-script.js'; 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) { function writePlan(plan) {
mkdirSync(tmpRoot, { recursive: true }); mkdirSync(tmpRoot, { recursive: true });
@@ -17,14 +22,17 @@ function writePlan(plan) {
return path; return path;
} }
const run = (plan, args = []) => const run = (plan, args = [], opts = {}) =>
runScript('schedule.js', ['--plan-file', writePlan(plan), ...args]); runScript('schedule.js', ['--plan-file', writePlan(plan), ...args], {
...opts,
env: { TEA_SDLC_CALENDAR_URL: CALENDAR, ...opts.env },
});
/** 依 index 取出算出來的截止日 */ /** 依 index 取出算出來的截止日 */
const dueOf = (json) => const dueOf = (json) =>
Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate])); Object.fromEntries(json.data.schedule.map((wp) => [wp.index, wp.dueDate]));
test('沒有相依時,每顆各自從起始日加上自己的人天', async () => { test('沒有相依時,每顆各自從起始日加上工作日人天', async () => {
const { code, json } = await run({ const { code, json } = await run({
startDate: '2026-09-21', startDate: '2026-09-21',
workPackages: [ workPackages: [
@@ -34,10 +42,10 @@ test('沒有相依時,每顆各自從起始日加上自己的人天', async ()
}); });
assert.equal(code, 0); 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({ const { json } = await run({
startDate: '2026-09-21', startDate: '2026-09-21',
workPackages: [ 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 () => { 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({ const { json } = await run({
startDate: '2026-09-21', startDate: '2026-12-24',
workPackages: [ workPackages: [{ index: 1, title: '跨假日', days: 2 }],
{ 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] },
],
}); });
const due = dueOf(json); assert.equal(dueOf(json)[1], '2026-12-29');
const depends = { 2: [1], 3: [2], 4: [1], 5: [3, 4] }; });
for (const [index, prerequisites] of Object.entries(depends)) {
for (const p of prerequisites) { test('日曆抓不到時只跳過週末並印出警告', async () => {
assert.ok(due[index] > due[p], `#${index} 的截止日不該早於先決 #${p}`); 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 () => { test('輸出的順序即拓撲順序,先決一定排在後續之前', async () => {
@@ -96,6 +107,54 @@ test('輸出的順序即拓撲順序,先決一定排在後續之前', async ()
assert.deepEqual(json.data.order, [1, 2, 3]); 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 () => { test('相依成環時中止,並指出環上的成員', async () => {
const { code, json } = await run({ const { code, json } = await run({
startDate: '2026-09-21', startDate: '2026-09-21',
@@ -161,7 +220,7 @@ test('計畫檔不是合法 JSON 時中止', async () => {
assert.equal(json.error.code, 'BAD_PLAN'); assert.equal(json.error.code, 'BAD_PLAN');
}); });
test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async () => { test('輸出保留標題、人天與日期欄位', async () => {
const { json } = await run({ const { json } = await run({
startDate: '2026-09-21', startDate: '2026-09-21',
workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }], workPackages: [{ index: 7, title: '建立抽取契約', days: 3 }],
@@ -172,13 +231,20 @@ test('輸出保留標題與人天,呼叫端不必回頭對照計畫檔', async
title: '建立抽取契約', title: '建立抽取契約',
days: 3, days: 3,
dueDate: '2026-09-24', 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 () => { test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () => {
const { code } = await runScript( const { code } = await run(
'schedule.js', { startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] },
['--plan-file', writePlan({ startDate: '2026-09-21', workPackages: [{ index: 1, title: '甲', days: 1 }] })], [],
{ env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } }, { env: { TEA_SDLC_CONFIG: '/nonexistent/tea.yml' } },
); );
@@ -186,7 +252,6 @@ test('這支腳本完全不碰 Gitea:沒有登入資訊也能算', async () =>
}); });
test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => { test('同一個 index 出現兩次時中止,不把兩份定義混在一起算', async () => {
// 混出來的結果會是:相依看後者、標題與人天取前者,而且完全不報錯
const { code, json } = await run({ const { code, json } = await run({
startDate: '2026-09-21', startDate: '2026-09-21',
workPackages: [ workPackages: [