Skip to content
33 changes: 7 additions & 26 deletions backend/controllers/scheduling/schedule-generate.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildSolverInput } from '../../services/scheduling/build-solver-input.j
import { runSolver } from '../../services/scheduling/solver-client.js'
import type { SolverSuccess, SolverOutput } from '../../services/scheduling/types/solver.js'
import { solverErrorMessage } from '../../services/scheduling/solver-errors.js'
import { generatedRows } from '../../services/scheduling/generated-rows.js'

/** Persiste el registro del run; nunca lanza — error de log no debe romper la respuesta. */
async function recordSolverRun(data: SolverRunRecord): Promise<void> {
Expand Down Expand Up @@ -164,32 +165,12 @@ export async function generateSchedule(req: Request, res: Response): Promise<voi
const success = solverOutput as SolverSuccess
const dayMap = new Map(days.map((d) => [d.day_number, d.id]))

const lockedSet = new Set<string>()
for (const [empId, dayDayMap] of Object.entries(solverInput.lockedCells)) {
for (const dayNum of Object.keys(dayDayMap)) {
lockedSet.add(`${empId}:${dayNum}`)
}
}

const toInsert: {
day_id: number
employee_id: string
shift_code: string
source_constraint_id: null
}[] = []
for (const [empId, dayShifts] of Object.entries(success.matrix)) {
for (const [dayNumStr, shiftCode] of Object.entries(dayShifts)) {
if (lockedSet.has(`${empId}:${dayNumStr}`)) continue
const dayId = dayMap.get(Number(dayNumStr))
if (!dayId) continue
toInsert.push({
day_id: dayId,
employee_id: empId,
shift_code: shiftCode,
source_constraint_id: null,
})
}
}
const persisted = new Set(
(await repo.getAssignmentsByMonth(monthId))
.filter((a) => a.source_constraint_id !== null)
.map((a) => `${a.employee_id}:${a.day_id}`)
)
const toInsert = generatedRows(success.matrix, solverInput.lockedCells, dayMap, persisted)

const { deleted, inserted } = await repo.applyGeneratedSchedule(monthId, toInsert)
logger.info({ monthId, deleted, inserted }, '[generate] transacción completada')
Expand Down
105 changes: 69 additions & 36 deletions backend/controllers/scheduling/scheduling-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,25 @@ function handleZodError(err: unknown, res: Response): boolean {
// CONFIG
// ============================================

// A published month is read-only: its cells, days and constraints change only
// after POST /months/:id/unpublish. Answers 409 and returns true when it is
async function refusePublished(monthId: number, res: Response): Promise<boolean> {
const month = await repo.getMonthById(monthId)
if (month?.status !== 'published') return false
res.status(409).json({ error: 'El mes está publicado: despublícalo para editarlo' })
return true
}

// A bulk edit carries day ids: all must be days of the month in the URL, or a
// day of another month (published or not) would be written through this one.
// Answers 400 and returns true when one is not
async function refuseForeignDays(monthId: number, dayIds: number[], res: Response) {
const unique = [...new Set(dayIds)]
if ((await repo.countDaysInMonth(monthId, unique)) === unique.length) return false
res.status(400).json({ error: 'Hay días que no pertenecen a este mes' })
return true
}

export async function getAllConfig(_req: Request, res: Response): Promise<void> {
try {
const config = await repo.getAllConfig()
Expand Down Expand Up @@ -809,25 +828,23 @@ export async function resetMonth(req: Request, res: Response): Promise<void> {
return
}

// Fix dates in scheduling_days (timezone issue fix)
const fixedCount = await repo.fixMonthDates(monthId, month.year, month.month)
logger.info({ monthId, fixedCount }, '[resetMonth] Fixed day dates')

// Delete all assignments
await repo.deleteAllAssignmentsByMonth(monthId)
logger.info({ monthId }, '[resetMonth] Deleted all assignments')

// Re-pre-load approved constraints as assignments
// The new grid: an L per day and employee, or the code of an approved request
const assignments = await initializeMonthGrid(monthId)

// Save assignments if any
if (assignments.length > 0) {
await repo.createAssignmentsBulk(monthId, assignments)
logger.info(
{ monthId, assignmentsCount: assignments.length },
'[resetMonth] Initialized assignments'
)
// Dates fixed, cells deleted and the grid inserted in one transaction: a
// failure leaves the month as it was, not empty
const fixedCount = await repo.resetMonthAssignments(
monthId,
month.year,
month.month,
assignments
)
logger.info(
{ monthId, fixedCount, assignmentsCount: assignments.length },
'[resetMonth] Month reset'
)

if (assignments.length > 0) {
// Number libre pairs immediately after seeding
const uniqueEmployees = new Set(assignments.map((a) => a.employee_id))
for (const empId of uniqueEmployees) {
Expand Down Expand Up @@ -867,6 +884,11 @@ export async function updateMonth(req: Request, res: Response): Promise<void> {
return
}

if (existing.status === 'published' && data.status === 'draft') {
res.status(409).json({ error: 'Para volver a borrador, despublica el mes' })
return
}

// Handle status changes
if (data.status === 'published' && existing.status !== 'published') {
await repo.updateMonth(monthId, {
Expand Down Expand Up @@ -934,6 +956,7 @@ export async function updateDay(req: Request, res: Response): Promise<void> {
res.status(404).json({ error: 'Día no encontrado' })
return
}
if (await refusePublished(existing.month_id, res)) return

await repo.updateDay(dayId, data)
const day = await repo.getDayById(dayId)
Expand Down Expand Up @@ -961,6 +984,15 @@ export async function bulkUpdateDays(req: Request, res: Response): Promise<void>
res.status(404).json({ error: 'Mes no encontrado' })
return
}
if (await refusePublished(monthId, res)) return
if (
await refuseForeignDays(
monthId,
data.days.map((d) => d.day_id),
res
)
)
return

const updatedCount = await repo.bulkUpdateDays(data.days)

Expand Down Expand Up @@ -996,6 +1028,7 @@ export async function updateAssignment(req: Request, res: Response): Promise<voi
res.status(404).json({ error: 'Asignación no encontrada' })
return
}
if (await refusePublished(existing.month_id, res)) return

if (existing.source_constraint_id) {
res.status(409).json({
Expand Down Expand Up @@ -1055,6 +1088,7 @@ export async function bulkUpdateAssignments(req: Request, res: Response): Promis
res.status(404).json({ error: 'Mes no encontrado' })
return
}
if (await refusePublished(monthId, res)) return

// Validate all shift codes
const shifts = await repo.getAllShifts()
Expand All @@ -1067,29 +1101,24 @@ export async function bulkUpdateAssignments(req: Request, res: Response): Promis
}
}

// Prevent editing locked cells (preloaded from approved constraints)
for (const assignment of data.assignments) {
const existing = await repo.getAssignmentByDayEmployee(
assignment.day_id,
assignment.employee_id
if (
await refuseForeignDays(
monthId,
data.assignments.map((a) => a.day_id),
res
)
if (existing?.source_constraint_id) {
res.status(409).json({
error:
'Hay celdas bloqueadas por peticiones aprobadas. No se pueden editar desde bulk update.',
})
return
}
}
)
return

// Upsert each assignment
for (const assignment of data.assignments) {
await repo.upsertAssignment({
month_id: monthId,
day_id: assignment.day_id,
employee_id: assignment.employee_id,
shift_code: assignment.shift_code,
// Cells locked by approved requests are checked and the rest written in
// one transaction
const { locked } = await repo.bulkUpsertAssignments(monthId, data.assignments)
if (locked) {
res.status(409).json({
error:
'Hay celdas bloqueadas por peticiones aprobadas. No se pueden editar desde bulk update.',
})
return
}

const affectedEmployees = new Set(data.assignments.map((a) => a.employee_id))
Expand Down Expand Up @@ -1203,6 +1232,7 @@ export async function createConstraint(req: Request, res: Response): Promise<voi
res.status(404).json({ error: 'Mes no encontrado' })
return
}
if (await refusePublished(data.month_id, res)) return

const constraintId = await repo.createConstraint({
...data,
Expand Down Expand Up @@ -1239,6 +1269,7 @@ export async function updateConstraint(req: Request, res: Response): Promise<voi
res.status(404).json({ error: 'Restricción no encontrada' })
return
}
if (await refusePublished(existing.month_id, res)) return

// An approved constraint moves its locked cells with it
if (existing.status === 'approved') await releaseConstraintCells(existing)
Expand Down Expand Up @@ -1283,6 +1314,7 @@ export async function approveConstraint(req: Request, res: Response): Promise<vo
res.status(404).json({ error: 'Restricción no encontrada' })
return
}
if (await refusePublished(existing.month_id, res)) return

await repo.updateConstraint(constraintId, {
status: data.status,
Expand Down Expand Up @@ -1328,6 +1360,7 @@ export async function deleteConstraint(req: Request, res: Response): Promise<voi
res.status(404).json({ error: 'Restricción no encontrada' })
return
}
if (await refusePublished(existing.month_id, res)) return

// Deleting an approved constraint frees its cells; the foreign key alone
// left the code in the cell without its lock
Expand Down
Loading
Loading