import type { SolveInput } from '~/shared/types/schedule' import { getDaysInRange, getWeekday } from '~/app/utils/dateHelpers' import type { ConstraintJSON } from '~/shared/types/constraint' export function buildModel(input: SolveInput) { const { employees, framework, period_start, period_end, constraints } = input const days = getDaysInRange(period_start, period_end) const E = employees.length const S = framework.shifts.length const D = days.length // Variable naming: x_e_s_d = 1 if employee e works shift s on day d const varNames: string[] = [] const varIndex: Record = {} for (let e = 0; e < E; e++) { for (let s = 0; s < S; s++) { for (let d = 0; d < D; d++) { const name = `x_${e}_${s}_${d}` varIndex[name] = varNames.length varNames.push(name) } } } // Penalty variables for soft constraints const penaltyVarStart = varNames.length let penaltyVarCount = 0 const softConstraintPenalties: Array<{ varIdx: number; weight: number }> = [] function addPenaltyVar(weight: number): number { const idx = penaltyVarStart + penaltyVarCount++ varNames.push(`penalty_${idx}`) softConstraintPenalties.push({ varIdx: idx, weight }) return idx } // Objective: minimize sum of soft penalties const objectiveCoeffs: Record = {} for (const p of softConstraintPenalties) { objectiveCoeffs[varNames[p.varIdx]] = p.weight } const bounds: Record = {} const binaries = new Set() for (const name of varNames) { bounds[name] = [0, 1] if (name.startsWith('x_')) binaries.add(name) } const rows: Array<{ name: string vars: Record lb: number ub: number }> = [] let rowIdx = 0 function addRow(vars: Record, lb: number, ub: number) { rows.push({ name: `r${rowIdx++}`, vars, lb, ub }) } // Structural constraint 1: at most 1 shift per employee per day for (let e = 0; e < E; e++) { for (let d = 0; d < D; d++) { const vars: Record = {} for (let s = 0; s < S; s++) { vars[`x_${e}_${s}_${d}`] = 1 } addRow(vars, 0, 1) } } // Structural constraint 2: coverage requirements for (let s = 0; s < S; s++) { const shift = framework.shifts[s] for (let d = 0; d < D; d++) { const weekday = getWeekday(days[d]) const applicable = shift.days_applicable.length === 0 || shift.days_applicable.includes(weekday) if (!applicable) { // Force all x_e_s_d = 0 for this day for (let e = 0; e < E; e++) { addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0) } continue } const coverageVars: Record = {} for (let e = 0; e < E; e++) { coverageVars[`x_${e}_${s}_${d}`] = 1 } addRow(coverageVars, shift.workers_required, E) } } // Structural constraint 3: availability for (let e = 0; e < E; e++) { const emp = employees[e] for (let d = 0; d < D; d++) { const date = days[d] // Block unavailable dates if (emp.unavailable_dates.includes(date)) { for (let s = 0; s < S; s++) { addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0) } continue } // Block periods not available for (let s = 0; s < S; s++) { const shift = framework.shifts[s] const period = framework.periods.find(p => p.id === shift.period_id) if (period && emp.available_periods.length > 0 && !emp.available_periods.includes(period.id)) { addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0) } } } } // Process constraints from ConstraintJSON for (const c of constraints) { const cj = c.constraint_json as ConstraintJSON const params = cj.params as Record // Determine affected employees let affectedEmployees: number[] = Array.from({ length: E }, (_, i) => i) if (cj.scope.type === 'employee') { const empIdx = employees.findIndex(e => e.id === (cj.scope as { type: 'employee'; employee_id: string }).employee_id) if (empIdx >= 0) affectedEmployees = [empIdx] else continue } else if (cj.scope.type === 'role') { const role = (cj.scope as { type: 'role'; role: string }).role affectedEmployees = employees .map((e, i) => ({ e, i })) .filter(({ e }) => e.roles.includes(role)) .map(({ i }) => i) } switch (cj.type) { case 'max_consecutive_shifts': { const max = params.max_count as number for (const e of affectedEmployees) { for (let d = 0; d <= D - (max + 1); d++) { const vars: Record = {} for (let dd = d; dd <= d + max; dd++) { for (let s = 0; s < S; s++) { vars[`x_${e}_${s}_${dd}`] = 1 } } addRow(vars, 0, max) } } break } case 'max_consecutive_shift_type': { const max = params.max_count as number const periodId = params.period_id as string const shiftIndices = framework.shifts .map((sh, i) => ({ sh, i })) .filter(({ sh }) => sh.period_id === periodId) .map(({ i }) => i) if (shiftIndices.length === 0) break for (const e of affectedEmployees) { for (let d = 0; d <= D - (max + 1); d++) { const vars: Record = {} for (let dd = d; dd <= d + max; dd++) { for (const s of shiftIndices) { vars[`x_${e}_${s}_${dd}`] = 1 } } addRow(vars, 0, max) } } break } case 'forbidden_shift_sequence': { const firstPeriod = params.first_period_id as string const secondPeriod = params.second_period_id as string const firstShifts = framework.shifts .map((sh, i) => ({ sh, i })) .filter(({ sh }) => sh.period_id === firstPeriod) .map(({ i }) => i) const secondShifts = framework.shifts .map((sh, i) => ({ sh, i })) .filter(({ sh }) => sh.period_id === secondPeriod) .map(({ i }) => i) for (const e of affectedEmployees) { for (let d = 0; d < D - 1; d++) { for (const s1 of firstShifts) { for (const s2 of secondShifts) { addRow({ [`x_${e}_${s1}_${d}`]: 1, [`x_${e}_${s2}_${d + 1}`]: 1 }, 0, 1) } } } } break } case 'employee_avoids_period': { const periodId = params.period_id as string const shiftIndices = framework.shifts .map((sh, i) => ({ sh, i })) .filter(({ sh }) => sh.period_id === periodId) .map(({ i }) => i) if (cj.hard) { for (const e of affectedEmployees) { for (let d = 0; d < D; d++) { for (const s of shiftIndices) { addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0) } } } } else { // Soft: penalty per assignment const weight = (c.weight || 70) * 10 for (const e of affectedEmployees) { for (let d = 0; d < D; d++) { for (const s of shiftIndices) { const pv = addPenaltyVar(weight) objectiveCoeffs[varNames[pv]] = weight bounds[varNames[pv]] = [0, 1] addRow({ [varNames[pv]]: 1, [`x_${e}_${s}_${d}`]: -1 }, 0, 1) } } } } break } case 'max_weekend_shifts_per_month': { const max = params.max_count as number const weekendDays = days .map((day, i) => ({ day, i })) .filter(({ day }) => { const wd = getWeekday(day) return wd === 5 || wd === 6 }) .map(({ i }) => i) for (const e of affectedEmployees) { const vars: Record = {} for (const d of weekendDays) { for (let s = 0; s < S; s++) { vars[`x_${e}_${s}_${d}`] = 1 } } if (Object.keys(vars).length > 0) { addRow(vars, 0, max) } } break } case 'min_rest_between_shifts': // Already handled implicitly by "max 1 shift per day" break default: break } } // Rebuild objective with finalized penalty vars for (const p of softConstraintPenalties) { objectiveCoeffs[varNames[p.varIdx]] = p.weight } // Initialize bounds for penalty vars for (let i = penaltyVarStart; i < varNames.length; i++) { if (!bounds[varNames[i]]) { bounds[varNames[i]] = [0, 1] } } return { name: 'ShiftCraft', sense: 'minimize' as const, offset: 0, col_lower: varNames.map(n => bounds[n]?.[0] ?? 0), col_upper: varNames.map(n => bounds[n]?.[1] ?? 1), col_cost: varNames.map(n => objectiveCoeffs[n] ?? 0), col_names: varNames, row_lower: rows.map(r => r.lb), row_upper: rows.map(r => r.ub), row_names: rows.map(r => r.name), a_matrix: { format: 'colwise' as const, start: buildColwiseStart(varNames, rows), index: buildColwiseIndex(varNames, rows), value: buildColwiseValues(varNames, rows), }, integrality: varNames.map(n => binaries.has(n) ? 1 : 0), } } function buildColwiseStart(varNames: string[], rows: Array<{ vars: Record }>): number[] { const starts: number[] = [0] let count = 0 for (const name of varNames) { for (const row of rows) { if (name in row.vars) count++ } starts.push(count) } return starts } function buildColwiseIndex(varNames: string[], rows: Array<{ vars: Record }>): number[] { const indices: number[] = [] for (const name of varNames) { rows.forEach((row, i) => { if (name in row.vars) indices.push(i) }) } return indices } function buildColwiseValues(varNames: string[], rows: Array<{ vars: Record }>): number[] { const values: number[] = [] for (const name of varNames) { for (const row of rows) { if (name in row.vars) values.push(row.vars[name]) } } return values }