/** * Group / room allocation — placing named attendees into the groups of an allocation * set ("Rooms", "Buses"…). Groups can have a capacity and a gender restriction. * The placement rules and auto-allocate are pure, so they're unit-tested. */ const GENDERS = ['male', 'female']; function allocationError(message, statusCode = 400) { const err = new Error(message); err.statusCode = statusCode; return err; } /** Normalises a gender value ('male' | 'female' | null); throws on anything else. */ function cleanGender(value) { if (value === undefined || value === null || value === '') return null; const g = String(value).trim().toLowerCase(); if (!GENDERS.includes(g)) throw allocationError('Gender must be male or female.'); return g; } /** * Why `attendees` can't all join `group` (which has `count` members already), or null. Pure. * @param {object} group - { name, capacity?, gender? } */ function joinBlocker(group, count, attendees) { if (group.capacity != null && count + attendees.length > group.capacity) { return `${group.name} is full (${group.capacity} places).`; } if (group.gender) { const wrong = attendees.find(a => a.gender !== group.gender); if (wrong) return `${group.name} is for ${group.gender === 'male' ? 'males' : 'females'} only${wrong.gender ? '' : ` — ${wrong.name} has no gender recorded`}.`; } return null; } /** * Places every unassigned attendee it can. Pure. * * Groups fill evenly (the emptiest group that fits goes first). With mode 'together', * people on the same registration (siblings) stay in one group when a group can take * them all, otherwise they're placed one by one. With mode 'split', people on the same * registration (friends booking together) are spread across groups where possible. * * @param {object} p * @param {Array} p.groups - [{ id, name, capacity?, gender?, order? }] * @param {Array} p.attendees - [{ id, name, registrationId, gender? }] * @param {Map} p.assigned - attendeeId → groupId for attendees already placed * @param {'together'|'split'} [p.mode] * @returns {{ placements: {attendeeId, groupId}[], unplaced: string[] }} */ function autoAllocate({ groups, attendees, assigned = new Map(), mode = 'together' }) { const count = new Map(groups.map(g => [g.id, 0])); const regInGroup = new Map(groups.map(g => [g.id, new Map()])); const byId = new Map(attendees.map(a => [a.id, a])); const place = (groupId, a) => { count.set(groupId, count.get(groupId) + 1); const regs = regInGroup.get(groupId); regs.set(a.registrationId, (regs.get(a.registrationId) || 0) + 1); }; for (const [attendeeId, groupId] of assigned) { const a = byId.get(attendeeId); if (a && count.has(groupId)) place(groupId, a); } const todo = attendees.filter(a => !assigned.has(a.id)); let units; if (mode === 'together') { const byReg = new Map(); for (const a of todo) byReg.set(a.registrationId, [...(byReg.get(a.registrationId) || []), a]); units = [...byReg.values()].sort((x, y) => y.length - x.length); } else { units = todo.map(a => [a]); } const orderOf = new Map(groups.map((g, i) => [g.id, g.order ?? i])); const best = (members) => { const reg = members[0].registrationId; const fitting = groups.filter(g => !joinBlocker(g, count.get(g.id), members)); if (fitting.length === 0) return null; fitting.sort((x, y) => (mode === 'split' ? (regInGroup.get(x.id).get(reg) || 0) - (regInGroup.get(y.id).get(reg) || 0) : 0) || count.get(x.id) - count.get(y.id) || orderOf.get(x.id) - orderOf.get(y.id)); return fitting[0]; }; const placements = []; const unplaced = []; const queue = [...units]; while (queue.length) { const unit = queue.shift(); const g = best(unit); if (g) { for (const a of unit) { place(g.id, a); placements.push({ attendeeId: a.id, groupId: g.id }); } } else if (unit.length > 1) { // Can't keep them together — place them one by one instead. queue.unshift(...unit.map(a => [a])); } else { unplaced.push(unit[0].id); } } return { placements, unplaced }; } module.exports = { GENDERS, allocationError, cleanGender, joinBlocker, autoAllocate };