Satu Fail, Setiap Baris
Kita bedah satu fail sebenar dari atas ke bawah: worker/oncall/fairness.ts — enjin yang menjana jadual on-call secara adil. Fokus bukan sekadar "apa ia buat", tetapi CARA & TEKNIK ia ditulis — corak yang anda boleh guna di mana-mana kod.
📑 Kandungan
- Kenapa fail ini paling banyak diajar
- Merekod Niat/Tujuan — komen di atas fail
- Import & types (SlotMode, EffectiveSlot)
- Pure function & Penjana nombor rawak (RNG)
- isUnavailable — Tapisan awal (guard clause)
- Model slot — Union dengan tag penyata
- State — Map & Set untuk kelajuan
- Kredit cuti/staf baru — Jangan ulang kod sama (DRY)
- Utiliti tarikh & susunan
- Enjin utama generateMonth (Algoritma pilihan terbaik semasa)
- tally — Mengira jumlah rekod
- Senarai teknik + seterusnya
Kenapa fail ini paling banyak diajar
Daripada 440 fail, ini fail terbaik untuk belajar cara menulis kod yang kemas dan baik. Ia fail yang paling bersih: fungsinya hanya mengira tanpa kesan sampingan (pure function), tidak mengacau pangkalan data, dan paling penting, mudah diuji. Teknik di sini sangat berguna untuk mana-mana projek anda nanti.
Tugas utamanya: menjana jadual on-call secara adil — bahagikan tugas sama rata, patuhi syarat keselamatan staf (jangan paksa kerja dua malam berturut-turut), dan sentiasa memberikan keputusan jadual yang sama jika dimasukkan data yang sama. Anda tak perlu faham kerja on-call hospital pun untuk pelajari teknik pengekodan ini.
Merekod Niat/Tujuan — komen di atas fail
Sebelum sebarang kod ditulis, fail ini menerangkan strategi & syarat wajib melalui komen. Teknik pertama: sentiasa catat "kenapa" kod ini ditulis, bukan "apa" yang ia buat.
// Pure fairness engine. No DB, no request context β fully unit-testable.
//
// Strategy: greedy chronological assignment. For each date, for each slot, pick
// the eligible candidate with the lowest fairness score. Fairness combines:
// - cumulative load (heavy shifts weighted), carried across months via history
// - a soft cross-month "skip a heavy shift the month after you did one" penalty
// - a seeded RNG tie-break so identical inputs always yield identical output
//
// HARD filters (R16): unavailability (per-date or leave-range) AND having worked
// the previous calendar day β a 48h no-sleep stretch is a safety issue, never a
// trade-off. If a slot's whole pool is filtered out, the slot is left empty and
// recorded as a conflict for the manager to resolve.
//
// R16 also adds a monthly duty cap (config.maxDutiesPerMonth β relaxed with a
// note when it would leave a day uncovered) and long-leave credit
// (config.leaveCreditMinDays β see leaveCredits()), so returning from maternity
// or a long course is not "repaid" with a catch-up pile-on.
Komen di atas tidak menerangkan cara kod berfungsi secara terperinci (kerana kod itu sendiri sudah jelas). Sebaliknya, ia merekodkan keputusan penting: kenapa algoritma pilihan-terbaik (greedy) digunakan, kenapa pangkalan data (DB) tidak disentuh, dan kenapa staf dilarang bekerja 48 jam tanpa rehat walau apa jua alasan. Enam bulan kemudian, nota ringkas inilah yang akan menyelamatkan anda dari pening kepala.
Import (type-only)
Fail mula dengan membawa masuk jenis-jenis yang ia kerjakan — jenis sahaja, bukan kod.
import type {
Assignment,
HistoryEntry,
HospitalConfig,
MonthSchedule,
Staff,
Unavailability,
} from './types';
import type memberitahu kompiler ‘ini jenis sahaja’ — ia dibuang sepenuhnya selepas build, jadi sifar kos masa jalan. Senarai ini juga mendokumen: fail ini bekerja dengan Staff, HospitalConfig, HistoryEntry, dll.
Pure function & Penjana nombor rawak (RNG) tetap
Fail ini dibina menggunakan banyak fungsi kecil yang fokus pada satu tugas (pure functions). Yang paling menarik di sini: penjana nombor rawak yang keputusannya boleh diramal (tetap), dan pengiraan tarikh yang bebas daripada zon waktu komputer.
/** Deterministic PRNG (mulberry32) so tie-breaks are reproducible. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** 'YYYY-MM-DD' for a given y/m(1-indexed)/d, computed without timezone drift. */
export function dateKey(year: number, month1: number, day: number): string {
return `${year}-${pad(month1)}-${pad(day)}`;
}
/** Days in a 1-indexed month. */
export function daysInMonth(year: number, month1: number): number {
return new Date(Date.UTC(year, month1, 0)).getUTCDate();
}
/** Day of week (0=Sun..6=Sat) for a 1-indexed month date. */
export function dayOfWeek(year: number, month1: number, day: number): number {
return new Date(Date.UTC(year, month1 - 1, day)).getUTCDay();
}
/** The 'YYYY-MM' string of the month immediately before the given one. */
function prevMonthPrefix(year: number, month1: number): string {
const m = month1 - 1;
return m === 0 ? `${year - 1}-12` : `${year}-${pad(m)}`;
}
Terdapat dua idea utama di sini. Pertama, Penjana Rawak Tetap (Determinisme): Fungsi rawak biasa akan memberikan hasil yang berbeza setiap kali dipanggil (menjadikannya sukar diuji). Namun, mulberry32(seed) akan sentiasa memberikan susunan nombor yang sama asalkan 'benih' (seed) yang diberi adalah sama. Ini juga merupakan contoh penggunaan fungsi bersarang (closure) — sebuah fungsi yang menghasilkan fungsi lain dan 'ingat' nilai asalnya antara setiap panggilan. Kedua, Pengiraan Tarikh menggunakan UTC: Pengiraan dibuat dengan standard Date.UTC agar zon waktu komputer tempatan tidak mengganggu hasil jadual.
isUnavailable — Tapisan awal (guard clause)
Satu fungsi boolean kecil yang menunjukkan cara menulis gelung yang mudah dibaca: keluar awal, elak syarat bersarang (nested) yang berserabut.
/** Is staff `id` unavailable on `date`? (date-block or leave-range covers it.) */
export function isUnavailable(
unavail: Unavailability[],
staffId: string,
date: string,
): boolean {
for (const u of unavail) {
if (u.staffId !== staffId) continue;
if (u.kind === 'date' && u.date === date) return true;
if (u.kind === 'range' && date >= u.from && date <= u.to) return true;
}
return false;
}
Penggunaan continue seawal mungkin (dipanggil guard atau tapisan awal) bertujuan membuang kes yang tidak relevan terlebih dahulu. Dengan ini, baris kod yang paling penting tidak terperangkap di dalam kurungan if yang berlapis-lapis. Perhatikan juga perbandingan tarikh: kerana format YYYY-MM-DD tersusun mengikut urutan secara semula jadi, kita boleh guna date >= u.from terus tanpa perlu membina objek Date yang rumit.
Model slot — Union dengan tag penyata
Di sini fail tetapan (config) diterjemahkan menjadi senarai tugasan harian yang jelas. Tekniknya: menggabungkan beberapa jenis data dan meletakkan tag supaya sistem tak keliru.
/**
* One concrete slot to fill on a given day. Per-team (2nd-call) slots expand into
* several of these β one per covering team β each with its own `${key}:${team}` id.
*/
/**
* How a concrete slot picks its assignee:
* - fairness: lowest-load eligible candidate (officer, DSA 2nd-call old path).
* - fixed: a constant assignee, no scoring (the 2nd-call specialist).
* - sequence: giliran tetap harian β deterministic daily cycle from an anchor.
* - weeklySequence: giliran tetap mingguan β deterministic MonβSun block cycle.
*/
type SlotMode = 'fairness' | 'fixed' | 'sequence' | 'weeklySequence';
interface EffectiveSlot {
/** Per-team slots carry `${slotKey}:${team}`; others just `slotKey`. */
key: string;
groups: string[];
mode: SlotMode;
team?: string;
/** sequence: the anchor date Β· weeklySequence: the anchor Monday. */
anchorDate?: string;
/** Cycle index occupied by the anchor date/week. */
anchorIndex?: number;
}
/**
* Expand a config's slots into the concrete slots to fill for one day-of-week.
* `poolByDow`/`groups`/`sequence` β one slot; `teamGroups`/`fixed`/`weeklySequence`
* β one slot per covering team. `selfPick` emits NOTHING (staff claim it later).
*/
function effectiveSlotsFor(config: HospitalConfig, dow: number): EffectiveSlot[] {
const out: EffectiveSlot[] = [];
for (const slot of config.slots) {
const src = slot.source;
if (src.kind === 'poolByDow') {
out.push({ key: slot.key, groups: src.poolByDow[dow] ?? [], mode: 'fairness' });
} else if (src.kind === 'groups') {
out.push({ key: slot.key, groups: src.groups, mode: 'fairness' });
} else if (src.kind === 'selfPick') {
continue; // pilih sendiri β engine leaves it empty; no slot emitted.
} else if (src.kind === 'sequence') {
out.push({
key: slot.key, groups: [src.group], mode: 'sequence',
anchorDate: src.anchorDate, anchorIndex: src.anchorIndex,
});
} else if (src.kind === 'weeklySequence') {
// 2nd-call: every covering team's slot is filled every day β the team that
// actually handles a case is decided at CALL TIME by case type (adult β
// OMFS, child β Paeds), not by which team's day this is. See HSR_CONFIG note.
for (const team of Object.keys(src.teamGroups)) {
const groups = src.teamGroups[team];
if (!groups || groups.length === 0) continue;
out.push({
key: `${slot.key}:${team}`, groups, mode: 'weeklySequence', team,
anchorDate: src.anchorMonday, anchorIndex: src.anchorIndex[team] ?? 0,
});
}
} else {
// teamGroups | fixed β same "every team, every day" rule as weeklySequence.
for (const team of Object.keys(src.teamGroups)) {
const groups = src.teamGroups[team];
if (!groups || groups.length === 0) continue;
out.push({
key: `${slot.key}:${team}`, groups,
mode: src.kind === 'fixed' ? 'fixed' : 'fairness', team,
});
}
}
}
return out;
}
SlotMode ialah Union: iaitu ia hanya membenarkan salah satu daripada 4 nilai teks tersebut — jika tersalah taip, komputer akan terus memberi amaran ralat. EffectiveSlot ialah bentuk objek (medan team? adalah pilihan/boleh tiada). Dalam effectiveSlotsFor, setiap slot.source mempunyai satu medan bernama kind yang berfungsi sebagai 'tag' atau penanda jenis. Teknik ini dipanggil Discriminated union: Selepas komputer membaca src.kind === 'sequence', komputer tahu dengan tepat medan apa yang sepatutnya wujud. Perhatikan bahawa untuk selfPick, kita sengaja tidak menghasilkan slot (keputusan reka bentuk ini dicatatkan pada komen).
A | B | C) — Data yang mesti jadi salah satu daripada senarai pilihan. Optional (?) — Medan yang tidak wajib / boleh tiada. Discriminated union — Data jenis union yang diselitkan dengan satu medan khas (seperti tag kind) untuk membezakan jenis. Object.keys — senarai nama bagi setiap kunci objek.
State — Map & Set untuk kelajuan
Enjin jadual ini perlu ingat data ‘siapa dah bertugas berapa kali’. Pemilihan struktur storan seperti Map & Set di sini sangat penting untuk memastikan sistem tak jadi perlahan.
/** Staff in any of `groups`. */
function inGroups(staff: Staff[], groups: string[]): Staff[] {
const set = new Set(groups);
return staff.filter((s) => set.has(s.group));
}
interface LoadState {
/** weighted load per (slot|staffId): heavy*heavyWeight + light. */
load: Map<string, number>;
/** staffIds who had a heavy shift in the immediately previous month. */
heavyLastMonth: Set<string>;
/** date(YYYY-MM-DD) β set of staffIds working any slot that day. */
byDate: Map<string, Set<string>>;
/**
* staffId β number of heavy (weekend/24h) shifts assigned THIS month so far.
* Drives the heavy-spread penalty so weekend burden is shared evenly; starts
* at 0 each generation (cross-month fairness is handled by heavyLastMonth).
*/
heavyCount: Map<string, number>;
/** staffId β fairness-slot duties assigned THIS month (drives maxDutiesPerMonth). */
dutiesThisMonth: Map<string, number>;
/** staffId β public holidays worked THIS calendar year (drives holidaySpreadPenalty). */
phThisYear: Map<string, number>;
}
function loadKey(slot: string, staffId: string): string {
return `${slot}|${staffId}`;
}
/** Seed load + last-month-heavy state from prior published history. */
function initLoad(
config: HospitalConfig,
history: HistoryEntry[],
year: number,
month1: number,
): LoadState {
const load = new Map<string, number>();
const heavyLastMonth = new Set<string>();
const byDate = new Map<string, Set<string>>();
const phThisYear = new Map<string, number>();
const prev = prevMonthPrefix(year, month1);
const yearPrefix = `${year}-`;
for (const h of history) {
const w = h.heavy ? config.weights.heavyWeight : 1;
const k = loadKey(h.slot, h.staffId);
load.set(k, (load.get(k) ?? 0) + w);
if (h.heavy && h.date.startsWith(prev)) heavyLastMonth.add(h.staffId);
if (h.date.startsWith(yearPrefix) && h.date in config.holidays) {
phThisYear.set(h.staffId, (phThisYear.get(h.staffId) ?? 0) + 1);
}
let set = byDate.get(h.date);
if (!set) byDate.set(h.date, (set = new Set()));
set.add(h.staffId);
}
return { load, heavyLastMonth, byDate, heavyCount: new Map(), dutiesThisMonth: new Map(), phThisYear };
}
Map ibarat kamus untuk kita mencari sesuatu dengan sangat pantas; Set pula ibarat senarai semak untuk menjawab soalan "dah ada ke belum?". loadKey menggabungkan dua maklumat (nama slot & nama staf) menjadi satu kunci carian (composite key). Corak penulisan map.set(k, (map.get(k) ?? 0) + n) bermaksud: ‘cuba ambil nilai lama, kalau tak ada, anggap ia 0, kemudian tambahkan dengan nilai baru dan simpan balik’. Kod ini berulang di seluruh fail!
Kredit cuti/staf baru — Jangan ulang kod sama (DRY)
Ada dua fungsi yang menyelesaikan masalah keadilan dengan cara yang lebih kurang sama. Jadi, daripada menulis kod yang sama dua kali, ia dipisahkan menjadi satu fungsi bantuan. Inilah yang dipanggil prinsip DRY.
Masalah: Algoritma akan memberi tugasan kepada staf yang mempunyai beban paling sedikit. Bayangkan seorang staf yang baru balik dari cuti bersalin panjang (atau staf yang baru lapor diri) — rekod tugasan mereka pasti sifar. Sistem secara automatik akan menimbunkan kerja tanpa henti kepada mereka untuk 'kejar' (catch up) kawan-kawan lain. Ini sangat tidak adil. Penyelesaian: Kita berikan mereka "kredit" percuma menyamai purata markah rakan sekerja pada bulan mereka tiada.
monthlyLoads — pembantu dikongsi (208–228)
/**
* R16 β kredit cuti panjang. For every history month in which a staff member was
* recorded unavailable for at least `config.leaveCreditMinDays` days, credit them
* the average weighted markah their PRESENT group-mates earned that month, per
* slot. Leave is not a debt: without this, someone returning from maternity (or a
* long course) carries near-zero load and the greedy engine piles duties on them
* until they "catch up" β e.g. 10 duties in their first month back.
* Returns loadKey(slot, staffId) β credit, merged into the load state at generate.
*/
/** Weighted markah earned per history month: month('YYYY-MM') β loadKey β markah. */
function monthlyLoads(config: HospitalConfig, history: HistoryEntry[]): Map<string, Map<string, number>> {
const monthLoads = new Map<string, Map<string, number>>();
for (const h of history) {
const month = h.date.slice(0, 7);
let slotMap = monthLoads.get(month);
if (!slotMap) monthLoads.set(month, (slotMap = new Map()));
const k = loadKey(h.slot, h.staffId);
slotMap.set(k, (slotMap.get(k) ?? 0) + (h.heavy ? config.weights.heavyWeight : 1));
}
return monthLoads;
}
leaveCredits — kredit cuti panjang (230–283)
export function leaveCredits(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
unavail: Unavailability[],
): Map<string, number> {
const credits = new Map<string, number>();
const minDays = config.leaveCreditMinDays;
if (minDays === undefined || history.length === 0) return credits;
const monthLoads = monthlyLoads(config, history);
const groupOf = new Map(staff.map((s) => [s.id, s.group]));
for (const [month, slotMap] of monthLoads) {
const [y, m1] = month.split('-').map(Number);
const total = daysInMonth(y, m1);
const onLeave = new Set<string>();
for (const s of staff) {
let blocked = 0;
for (let d = 1; d <= total; d++) {
if (isUnavailable(unavail, s.id, dateKey(y, m1, d))) blocked++;
}
if (blocked >= minDays) onLeave.add(s.id);
}
if (onLeave.size === 0) continue;
const presentCount = new Map<string, number>();
for (const s of staff) {
if (!onLeave.has(s.id)) presentCount.set(s.group, (presentCount.get(s.group) ?? 0) + 1);
}
// Markah earned that month per (slot, group) by present members. Group
// membership IS pool membership for fairness slots, so the group average is
// exactly "what an average available colleague carried".
const slotGroupSum = new Map<string, number>();
for (const [k, earned] of slotMap) {
const [slot, staffId] = k.split('|');
const group = groupOf.get(staffId);
if (!group || onLeave.has(staffId)) continue;
const sg = `${slot}|${group}`;
slotGroupSum.set(sg, (slotGroupSum.get(sg) ?? 0) + earned);
}
for (const s of staff) {
if (!onLeave.has(s.id)) continue;
const present = presentCount.get(s.group) ?? 0;
if (present === 0) continue;
for (const [sg, sum] of slotGroupSum) {
const [slot, group] = sg.split('|');
if (group !== s.group) continue;
const k = loadKey(slot, s.id);
credits.set(k, (credits.get(k) ?? 0) + sum / present);
}
}
}
return credits;
}
newJoinerCredits — kredit staf baru (285–335)
/**
* R20-C β new-joiner fairness seed. For every history month that ended wholly
* before a staff member's joinedAt, credit them the average weighted markah
* their group-mates earned that month, per slot (same mechanism as
* leaveCredits). Without it a newcomer carries zero load and the greedy engine
* gives them the monthly cap every month until they "catch up" β joining late
* is not a debt. Joining mid-month counts as present for that month (no credit).
*/
export function newJoinerCredits(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
): Map<string, number> {
const credits = new Map<string, number>();
if (history.length === 0 || !staff.some((s) => s.joinedAt)) return credits;
const monthLoads = monthlyLoads(config, history);
const groupOf = new Map(staff.map((s) => [s.id, s.group]));
for (const [month, slotMap] of monthLoads) {
// Pre-membership = joined in a LATER month than this history month.
const preMember = new Set(
staff.filter((s) => s.joinedAt && s.joinedAt.slice(0, 7) > month).map((s) => s.id),
);
if (preMember.size === 0) continue;
const presentCount = new Map<string, number>();
for (const s of staff) {
if (!preMember.has(s.id)) presentCount.set(s.group, (presentCount.get(s.group) ?? 0) + 1);
}
const slotGroupSum = new Map<string, number>();
for (const [k, earned] of slotMap) {
const [slot, staffId] = k.split('|');
const group = groupOf.get(staffId);
if (!group || preMember.has(staffId)) continue;
const sg = `${slot}|${group}`;
slotGroupSum.set(sg, (slotGroupSum.get(sg) ?? 0) + earned);
}
for (const s of staff) {
if (!preMember.has(s.id)) continue;
const present = presentCount.get(s.group) ?? 0;
if (present === 0) continue;
for (const [sg, sum] of slotGroupSum) {
const [slot, group] = sg.split('|');
if (group !== s.group) continue;
const k = loadKey(slot, s.id);
credits.set(k, (credits.get(k) ?? 0) + sum / present);
}
}
}
return credits;
}
Kedua-dua fungsi kredit tadi mempunyai struktur yang sama: cari siapa yang tiada pada bulan itu, kirakan purata markah rakan mereka yang hadir, lalu berikan purata markah itu kepada yang tiada tadi. Logik pengiraan ini diletakkan dalam satu fungsi berasingan (monthlyLoads) supaya ia ditulis sekali, tetapi boleh diguna dua kali. Setiap fungsi juga bermula dengan tapisan awal (keluar fungsi kalau tiada kerja nak dibuat).
Utiliti tarikh & susunan
Lima fungsi kecil sokongan. Dua perkara menarik: pembahagian baki (true modulo) dan susunan data tanpa mengacau senarai asal.
/** Shift the calendar date by `delta` days, returning a 'YYYY-MM-DD' key. */
function shiftDate(date: string, delta: number): string {
const [y, m, d] = date.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + delta));
return `${dt.getUTCFullYear()}-${pad(dt.getUTCMonth() + 1)}-${pad(dt.getUTCDate())}`;
}
/** Whole calendar days from `from` to `to` (signed; to β from). Both 'YYYY-MM-DD'. */
function daysBetween(from: string, to: string): number {
const [fy, fm, fd] = from.split('-').map(Number);
const [ty, tm, td] = to.split('-').map(Number);
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / 86_400_000);
}
/** The Monday (ISO week start) of the week containing `date`, as 'YYYY-MM-DD'. */
function mondayOf(date: string): string {
const [y, m, d] = date.split('-').map(Number);
const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Sun..6=Sat
return shiftDate(date, dow === 0 ? -6 : 1 - dow); // Sunβprev Mon, else back to Mon
}
/** True modulo (always 0..m-1, even for negative n) β anchors may precede `date`. */
function trueMod(n: number, m: number): number {
return ((n % m) + m) % m;
}
/** Order a group's pool by its cycle position (sortOrder), id as a stable tie-break. */
function byCycle(pool: Staff[]): Staff[] {
return [...pool].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id.localeCompare(b.id),
);
}
Dalam JavaScript, pengiraan -1 % 3 akan memberikan -1 (sepatutnya 2) — ini jadi masalah besar bila kita nak kira pusingan jadual ke belakang. Fungsi trueMod membetulkan hal ini supaya sentiasa memberikan nombor positif. Manakala untuk byCycle, ia menunjukkan dua teknik: penggunaan [...pool] untuk 'menyalinkan' senarai staf terlebih dahulu supaya fungsi .sort() tidak mengubah senarai staf asal secara kekal; dan || a.id.localeCompare(b.id) yang berfungsi sebagai pemutus seri yang konsisten (stable tie-break) — jika markah seri, susunan akan sentiasa dipecahkan mengikut ID staf.
Enjin utama generateMonth (Algoritma pilihan terbaik semasa)
Semua logik tadi berkumpul di sini. Inilah nadi utama fail ini: gelung yang bergerak dari hari ke hari, slot demi slot, untuk memilih calon paling adil — tetapi hanya selepas membuang mereka yang tidak cukup rehat (tapis keselamatan).
9a. Persediaan — satu sumber rawak, satu state (370–394)
/**
* Generate one month's schedule.
* @param month1 1-indexed month (1=Jan..12=Dec).
* @param seed any integer; same inputs + seed β identical output.
*/
export function generateMonth(
config: HospitalConfig,
staff: Staff[],
history: HistoryEntry[],
unavail: Unavailability[],
year: number,
month1: number,
seed = 1,
): MonthSchedule {
const rng = mulberry32(seed);
const state = initLoad(config, history, year, month1);
for (const [k, credit] of leaveCredits(config, staff, history, unavail)) {
state.load.set(k, (state.load.get(k) ?? 0) + credit);
}
for (const [k, credit] of newJoinerCredits(config, staff, history)) {
state.load.set(k, (state.load.get(k) ?? 0) + credit);
}
const assignments: Assignment[] = [];
const conflicts: { date: string; slot: string; reason: string }[] = [];
const total = daysInMonth(year, month1);
9b. Gelung harian + giliran tetap (396–431)
for (let day = 1; day <= total; day++) {
const date = dateKey(year, month1, day);
const dow = dayOfWeek(year, month1, day);
const isHoliday = date in config.holidays;
const heavy = config.weekendDows.includes(dow) || isHoliday;
const todaysAssigned = state.byDate.get(date) ?? new Set<string>();
state.byDate.set(date, todaysAssigned);
const prevDate = shiftDate(date, -1);
const prevAssigned = state.byDate.get(prevDate) ?? new Set<string>();
for (const es of effectiveSlotsFor(config, dow)) {
// Giliran tetap (sequence / weeklySequence): the assignee is pure maths from
// an anchor, ordered by sortOrder. NO unavailability skipping and NO fairness
// β predictability is the whole point; a clash is resolved by a swap, not by
// the engine silently picking someone else.
if (es.mode === 'sequence' || es.mode === 'weeklySequence') {
const cycle = byCycle(inGroups(staff, es.groups));
if (cycle.length === 0) {
const why = 'Tiada staf dalam pool untuk slot ini pada hari ini.';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
const steps =
es.mode === 'sequence'
? daysBetween(es.anchorDate!, date)
: Math.round(daysBetween(es.anchorDate!, mondayOf(date)) / 7);
const chosen = cycle[trueMod((es.anchorIndex ?? 0) + steps, cycle.length)];
todaysAssigned.add(chosen.id);
assignments.push({
date, slot: es.key, staffId: chosen.id, heavy,
reason: `${chosen.name}: giliran tetap${heavy ? ' (24j)' : ''}`,
team: es.team,
});
continue;
}
9c. Kelayakan — buang yang cuti / sudah bertugas (433–446)
const pool = inGroups(staff, es.groups);
const eligible = pool.filter(
(s) => !isUnavailable(unavail, s.id, date) && !todaysAssigned.has(s.id),
);
if (eligible.length === 0) {
const why =
pool.length === 0
? 'Tiada staf dalam pool untuk slot ini pada hari ini.'
: 'Semua calon tidak tersedia (cuti / sudah bertugas hari ini).';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
9d. Pakar tetap (fixed) — pilihan deterministik (448–462)
// Fixed specialist: constant assignee, no fairness scoring and no load
// accumulation β pick deterministically by id so output is reproducible.
if (es.mode === 'fixed') {
const chosen = [...eligible].sort((a, b) => a.id.localeCompare(b.id))[0];
todaysAssigned.add(chosen.id);
assignments.push({
date,
slot: es.key,
staffId: chosen.id,
heavy,
reason: `${chosen.name}: pakar tetap${heavy ? ' (24j)' : ''}`,
team: es.team,
});
continue;
}
9e. Tapisan KERAS R16 + had bulanan (464–486)
// R16 hard rule β tiada hari berturut. Working yesterday (any slot) is a
// hard filter for fairness slots: 48h without sleep is a safety issue, not
// a preference. An empty pool here becomes a manager-visible gap β the
// engine never trades sleep for coverage.
const rested = eligible.filter((s) => !prevAssigned.has(s.id));
if (rested.length === 0) {
const why = 'Semua calon bertugas semalam β peraturan tiada hari berturut.';
assignments.push({ date, slot: es.key, staffId: null, heavy, reason: why, team: es.team });
conflicts.push({ date, slot: es.key, reason: why });
continue;
}
// R16 β had bulanan. Coverage beats the cap: when every rested candidate
// is already at it, relax with a note instead of leaving the day empty.
let candidates = rested;
let capNote = '';
if (config.maxDutiesPerMonth !== undefined) {
const underCap = rested.filter(
(s) => (state.dutiesThisMonth.get(s.id) ?? 0) < config.maxDutiesPerMonth!,
);
if (underCap.length > 0) candidates = underCap;
else capNote = ', melebihi had bulanan (tiada calon lain)';
}
9f. Pemarkahan keadilan + tie-break (488–524)
let best: Staff | null = null;
let bestScore = Infinity;
let bestNote = '';
for (const cand of candidates) {
const base = state.load.get(loadKey(es.key, cand.id)) ?? 0;
let score = base;
const notes: string[] = [`beban=${base.toFixed(0)}`];
if (isHoliday && config.weights.holidaySpreadPenalty !== undefined) {
// R17: the next PH goes to whoever has done the fewest PHs this
// calendar year. Outranks the within-month heavy spread below.
const phCount = state.phThisYear.get(cand.id) ?? 0;
if (phCount > 0) {
score += phCount * config.weights.holidaySpreadPenalty;
notes.push('agih cuti umum setahun');
}
}
if (heavy) {
// Share weekends evenly: anyone who already has a heavy shift this month
// is pushed far down the list, so the next weekend goes to someone with
// none until everyone has had one. Dominates ordinary load differences.
const hc = state.heavyCount.get(cand.id) ?? 0;
if (hc > 0) {
score += hc * config.weights.heavySpreadPenalty;
notes.push('agih hujung-minggu rata');
}
}
if (heavy && state.heavyLastMonth.has(cand.id)) {
score += config.weights.skipWeekendPenalty;
notes.push('elak ulang hujung-minggu');
}
score += rng() * 0.001; // deterministic tie-break, never overrides real signal
if (score < bestScore) {
best = cand;
bestScore = score;
bestNote = notes.join(', ');
}
}
9g. Sahkan pilihan + kemas state + slot sampingan (526–557)
// best is non-null: candidates.length > 0 guaranteed a candidate above.
const chosen = best as Staff;
const k = loadKey(es.key, chosen.id);
state.load.set(k, (state.load.get(k) ?? 0) + (heavy ? config.weights.heavyWeight : 1));
if (heavy) state.heavyCount.set(chosen.id, (state.heavyCount.get(chosen.id) ?? 0) + 1);
if (isHoliday) state.phThisYear.set(chosen.id, (state.phThisYear.get(chosen.id) ?? 0) + 1);
state.dutiesThisMonth.set(chosen.id, (state.dutiesThisMonth.get(chosen.id) ?? 0) + 1);
todaysAssigned.add(chosen.id);
assignments.push({
date,
slot: es.key,
staffId: chosen.id,
heavy,
reason: `${chosen.name}: ${bestNote}${capNote}${heavy ? ' (24j)' : ''}`,
team: es.team,
});
// KPSR / KPTM officer day: the unit covers, but the actual doctor on call is
// chosen by that unit. Emit an EMPTY `primer_doctor:<unit>` slot for the day so
// the unit's doctor can self-fill it later. Not a conflict β an expected blank.
if (chosen.group === 'primer') {
const unit = chosen.name.toLowerCase(); // 'kpsr' | 'kptm' β matches kpsr_doctor/kptm_doctor
assignments.push({
date,
slot: `primer_doctor:${unit}`,
staffId: null,
heavy,
reason: `Doktor unit ${chosen.name} akan pilih sendiri.`,
team: unit,
});
}
}
9h. Tutup gelung + pulangkan jadual (558–561)
}
return { hospitalId: config.hospitalId, year, month: month1, assignments, conflicts };
}
Ada tiga keputusan reka bentuk yang menonjol. Keselamatan staf ialah tapisan wajib (hard filter), bukan sekadar markah: Staf yang bercuti atau bertugas semalam akan dibuang terus dari senarai kelayakan awal-awal lagi (lihat 9c, 9e) — keselamatan tak boleh ditukar ganti dengan skor. Kedua, Slot tanpa staf dibiarkan kosong & direkod sebagai konflik untuk pengurus selesaikan secara manual — sistem tidak akan memaksa untuk menyumbat staf yang kepenatan. Ketiga, Nombor rawak hanya digunakan sebagai pemutus seri (rng() * 0.001) — ia takkan mengubah perkiraan keadilan markah utama. Sistem juga meletakkan reason (alasan) pada setiap tugasan yang diberi supaya pengurus tahu kenapa sistem membuat pilihan itu.
tally — Mengira rekod jumlah
Fungsi terakhir: mengira jumlah tugasan setiap orang untuk dipaparkan di papan pemuka (dashboard). Ini adalah contoh bersih bagaimana memproses senarai panjang menjadi satu ringkasan pendek.
/** Per-(slot,staff) tally for the fairness dashboard / "Kiraan Tugas". */
export function tally(schedule: MonthSchedule): Record<string, { heavy: number; light: number }> {
const out: Record<string, { heavy: number; light: number }> = {};
for (const a of schedule.assignments) {
if (!a.staffId) continue;
const k = loadKey(a.slot, a.staffId);
const t = (out[k] ??= { heavy: 0, light: 0 });
if (a.heavy) t.heavy++;
else t.light++;
}
return out;
}
Corak pengekodan meringkas senarai (reduce): bermula dengan bekas kosong, masuk ke dalam gelung, dan kumpul jumlah satu per satu. Penggunaan out[k] ??= {...} bermaksud "cipta ruangan kosong jika belum ada" — ia memendekkan tiga baris kod panjang if (!out[k]) out[k] = {...} menjadi satu baris sahaja. Logik utama juga sentiasa bermula dengan tapisan awal (guard): if (!a.staffId) continue.
Senarai teknik & langkah seterusnya
Satu fail, tapi sangat kaya dengan teknik pengekodan. Inilah rumusan apa yang anda baru pelajari — semuanya berguna untuk dipraktikkan di mana-mana projek.
| Teknik | Di mana dalam fail |
|---|---|
| Komen "kenapa" + Catatan tujuan kod di atas fail | s1 (baris 1–17) |
| Union & interface ("Jadikan kesilapan itu mustahil") | s2 (SlotMode, EffectiveSlot) |
| Pure function + Penjana rawak tetap (seed RNG) | s3 (mulberry32) |
| Fungsi bersarang (Closure) + tarikh piawai (UTC) | s3 (dateKey, daysInMonth) |
| Tapisan awal untuk keluar cepat (Guard clause) | s4 (isUnavailable), s9c, s10 |
| Union ber-tag (Discriminated union mengikut kind) | s5 (effectiveSlotsFor) |
| Storan kelajuan tinggi (Map/Set) + kunci gabungan | s6 (LoadState, loadKey) |
| Jangan ulang kod (DRY) + ekstrak fungsi pembantu | s7 (monthlyLoads) |
| Bahagi baki yang betul + salin-sebelum-sort | s8 (trueMod, byCycle) |
| Tapis keselamatan wajib dulu, kira skor markah kemudian | s9 (generateMonth) |
| Pilih untuk gagal terang-terangan berbanding buat silap senyap | s9c/9e |
| Meringkas data senarai ke dalam rekod menggunakan ??= | s10 (tally) |
Buka worker/oncall/fairness.ts dan terangkan fungsi generateMonth baris demi baris penuh. Saya faham idea greedy + tapisan keras dari panduan ini — sekarang tunjuk setiap baris yang saya lompat.
Dalam fairness.ts, kenapa rng() didarab 0.001 dan bukan nombor besar? Apa jadi pada hasil kalau saya buang baris tie-break itu?