1101 lines
41 KiB
TypeScript
1101 lines
41 KiB
TypeScript
//import { getToken } from "next-auth/jwt";
|
|
|
|
import axiosServer from '../../src/axiosServer';
|
|
import { getToken } from "next-auth/jwt";
|
|
|
|
import { set, format, addDays } from 'date-fns';
|
|
|
|
import type { NextApiRequest, NextApiResponse } from "next";
|
|
import { Prisma, PrismaClient, DayOfWeek, Publisher, Shift } from "@prisma/client";
|
|
import { levenshteinEditDistance } from "levenshtein-edit-distance";
|
|
import { filterPublishers, /* other functions */ } from './index';
|
|
|
|
import CAL from "../../src/helpers/calendar";
|
|
//const common = require("@common");
|
|
import common, { logger } from "../../src/helpers/common";
|
|
import data from "../../src/helpers/data";
|
|
import { Axios } from 'axios';
|
|
|
|
export default handler;
|
|
|
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
console.log(req.url);
|
|
console.log(req.query);
|
|
const prisma = common.getPrismaClient();
|
|
|
|
// If you don't have the NEXTAUTH_SECRET environment variable set,
|
|
// you will have to pass your secret as `secret` to `getToken`
|
|
const axios = await axiosServer({ req: req, res: res });
|
|
const token = await getToken({ req: req });
|
|
if (!token) {
|
|
// If no token or invalid token, return unauthorized status
|
|
return res.status(401).json({ message: "Unauthorized" });
|
|
}
|
|
|
|
// const token = req.headers.authorization.split('Bearer ')[1]
|
|
// const { user } = await verify(token, process.env.NEXTAUTH_SECRET, {
|
|
// maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
// })
|
|
// if (!user.roles.includes('admin')) {
|
|
// res.status(401).json({ message: 'Unauthorized' })
|
|
// return
|
|
// }
|
|
// // if (!user.role == "adminer") {
|
|
|
|
// if (token?.userRole !== "adminer") {
|
|
// res.status(401).json({ message: "Unauthorized" });
|
|
// console.log("not authorized");
|
|
// return;
|
|
// }
|
|
|
|
// var result = { error: "Not authorized" };
|
|
var action = req.query.action;
|
|
switch (action) {
|
|
case "generate":
|
|
var date = req.query.date?.toString() || common.getISODateOnly(new Date());
|
|
var copyFromPreviousMonth = common.parseBool(req.query.copyFromPreviousMonth);
|
|
var autoFill = common.parseBool(req.query.autoFill);
|
|
var forDay = common.parseBool(req.query.forDay);
|
|
var type = parseInt(req.query.type) || 0;
|
|
if (type == 2) {
|
|
var result = await GenerateOptimalSchedule(axios, date, copyFromPreviousMonth, autoFill, forDay, type);
|
|
}
|
|
else {
|
|
var result = await GenerateSchedule(axios, date, copyFromPreviousMonth, autoFill, forDay, type);
|
|
}
|
|
res.send(JSON.stringify(result?.error?.toString()));
|
|
break;
|
|
case "delete":
|
|
result = await DeleteSchedule(axios, req.query.date, common.parseBool(req.query.forDay));
|
|
let msg = "Deleted schedule for " + (req.query.forDay ? req.query.date : "the entire month of ") + req.query.date + ". Action requested by " + token.email;
|
|
logger.warn(msg);
|
|
console.log(msg);
|
|
res.send("deleted"); // JSON.stringify(result, null, 2)
|
|
break;
|
|
case "createcalendarevent":
|
|
//CAL.GenerateICS();
|
|
result = await CreateCalendarForUser(req.query.id);
|
|
res.send(result); // JSON.stringify(result, null, 2)
|
|
break;
|
|
case "test":
|
|
var data = prisma.shift.findMany({
|
|
where: {
|
|
isActive: true
|
|
}
|
|
});
|
|
|
|
res.send({
|
|
action: "OK",
|
|
shifts: data,
|
|
locations: prisma.location.findMany({
|
|
take: 10, // Limit the number of records to 10
|
|
orderBy: {
|
|
name: 'asc' // Replace 'someField' with a field you want to sort by
|
|
},
|
|
})
|
|
});
|
|
break;
|
|
default:
|
|
res.send("Invalid action");
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ### COPIED TO shift api (++) ###
|
|
|
|
|
|
let scheduledPubsPerDayAndWeek = {};
|
|
// Function to flatten the registry
|
|
// Function to update the registry
|
|
function updateRegistry(publisherId, day, weekNr) {
|
|
// Registry schema: {day: {weekNr: [publisherIds]}}
|
|
const dayKey = common.getISODateOnly(day);
|
|
if (!scheduledPubsPerDayAndWeek[dayKey]) {
|
|
scheduledPubsPerDayAndWeek[dayKey] = {};
|
|
}
|
|
if (!scheduledPubsPerDayAndWeek[dayKey][weekNr]) {
|
|
scheduledPubsPerDayAndWeek[dayKey][weekNr] = [];
|
|
}
|
|
scheduledPubsPerDayAndWeek[dayKey][weekNr].push(publisherId);
|
|
}
|
|
function flattenRegistry(dayKey) {
|
|
const weekEntries = scheduledPubsPerDayAndWeek[dayKey] || {};
|
|
return Object.values(weekEntries).flat();
|
|
}
|
|
|
|
async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay, algType = 0) {
|
|
|
|
let missingPublishers = [];
|
|
let publishersWithChangedPref = [];
|
|
|
|
const prisma = common.getPrismaClient();
|
|
try {
|
|
const monthInfo = common.getMonthDatesInfo(new Date(date));
|
|
const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1));
|
|
|
|
if (forDay) {
|
|
await DeleteShiftsForDay(monthInfo.date);
|
|
} else {
|
|
await DeleteShiftsForMonth(monthInfo);
|
|
}
|
|
|
|
const events = await prisma.cartEvent.findMany({
|
|
where: {
|
|
isActive: true
|
|
}
|
|
});
|
|
let shiftsLastMonth = await getShiftsFromLastMonth(lastMonthInfo);
|
|
let publishers = await data.getAllPublishersWithStatisticsMonth(date, false, false);
|
|
|
|
let shiftAssignments = [];
|
|
let day = new Date(monthInfo.firstMonday);
|
|
let endDate = monthInfo.lastSunday;
|
|
let dayNr = 1;
|
|
let weekNr = 1;
|
|
|
|
|
|
if (forDay) {
|
|
day = monthInfo.date;
|
|
endDate.setDate(monthInfo.date.getDate() + 1);
|
|
dayNr = monthInfo.date.getDate();
|
|
weekNr = common.getWeekNumber(monthInfo.date);
|
|
}
|
|
|
|
let publishersThisWeek = [];
|
|
|
|
|
|
|
|
// 0. generate shifts and assign publishers from the previous month if still available
|
|
while (day < endDate) {
|
|
let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false);
|
|
console.log("passing schedule generation for " + day.toLocaleDateString());
|
|
const dayOfM = day.getDate();
|
|
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day);
|
|
let dayName = common.DaysOfWeekArray[day.getDayEuropean()];
|
|
const event = events.find((event) => event.dayofweek == dayName && (event.dayOfMonth == null || event.dayOfMonth == dayOfM));
|
|
|
|
if (!event) {
|
|
day.setDate(day.getDate() + 1);
|
|
continue;
|
|
}
|
|
|
|
event.startTime = new Date(event.startTime);
|
|
event.endTime = new Date(event.endTime);
|
|
|
|
let startTime = new Date(day);
|
|
startTime.setHours(event.startTime.getHours());
|
|
startTime.setMinutes(event.startTime.getMinutes());
|
|
let endTime = new Date(day);
|
|
endTime.setHours(event.endTime.getHours());
|
|
endTime.setMinutes(event.endTime.getMinutes());
|
|
|
|
let shiftStart = new Date(startTime);
|
|
let shiftEnd = new Date(startTime);
|
|
shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration);
|
|
|
|
let shiftNr = 0;
|
|
while (shiftEnd <= endTime) {
|
|
shiftNr++;
|
|
const __shiftName = String(shiftStart.getHours()).padStart(2, "0") + ":" + String(shiftStart.getMinutes()).padStart(2, "0") + " - " + String(shiftEnd.getHours()).padStart(2, "0") + ":" + String(shiftEnd.getMinutes()).padStart(2, "0");
|
|
shiftAssignments = [];
|
|
let isTransportRequired = shiftNr == 1 || shiftEnd.getTime() == endTime.getTime();
|
|
|
|
const shiftLastMonthSameDay = findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr);
|
|
|
|
if (shiftLastMonthSameDay) {
|
|
for (let assignment of shiftLastMonthSameDay.assignments) {
|
|
let publisher = assignment.originalPublisher ?? assignment.publisher;
|
|
console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName + assignment.originalPublisher ? " (original)" : "");
|
|
let availability = await FindPublisherAvailability(publisher.id, shiftStart, shiftEnd, dayOfWeekEnum, weekNr);
|
|
console.log("availability " + availability?.id + ": " + common.getDateFormattedShort(availability?.startTime) + " " + common.getTimeFormatted(availability?.startTime) + " - " + common.getTimeFormatted(availability?.endTime));
|
|
|
|
if (availability && copyFromPreviousMonth && !publishersThisWeek.includes(publisher.id)) {
|
|
shiftAssignments.push({
|
|
publisherId: publisher.id,
|
|
isConfirmed: true,
|
|
isWithTransport: availability.isWithTransportIn || availability.isWithTransportOut
|
|
});
|
|
publishersThisWeek.push(publisher.id);
|
|
updateRegistry(publisher.id, day, weekNr);
|
|
}
|
|
}
|
|
}
|
|
|
|
let publishersNeeded = event.numberOfPublishers - shiftAssignments.length;
|
|
//ToDo: check if getAvailablePublishersForShift is working correctly. It seems not to!
|
|
let availablePublishers = await getAvailablePublishersForShiftNew(shiftStart, shiftEnd, availablePubsForTheDay, publishersThisWeek);
|
|
|
|
console.log("shift " + __shiftName + " needs " + publishersNeeded + " publishers, available: " + availablePublishers.length + " for the day: " + availablePubsForTheDay.length);
|
|
|
|
const createdShift = await prisma.shift.create({
|
|
data: {
|
|
startTime: shiftStart,
|
|
endTime: shiftEnd,
|
|
name: event.dayofweek + " " + shiftStart.toLocaleTimeString() + " - " + shiftEnd.toLocaleTimeString(),
|
|
requiresTransport: isTransportRequired,
|
|
cartEvent: {
|
|
connect: {
|
|
id: event.id,
|
|
},
|
|
},
|
|
assignments: {
|
|
create: shiftAssignments.map((a) => {
|
|
return {
|
|
publisher: {
|
|
connect: { id: a.publisherId }
|
|
},
|
|
isWithTransport: a.isWithTransport,
|
|
isConfirmed: a.isConfirmed,
|
|
isBySystem: true,
|
|
};
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
shiftStart = new Date(shiftEnd);
|
|
shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration);
|
|
}
|
|
|
|
day.setDate(day.getDate() + 1);
|
|
dayNr++;
|
|
if (common.DaysOfWeekArray[day.getDayEuropean()] === DayOfWeek.Sunday) {
|
|
weekNr++;
|
|
publishersThisWeek = [];
|
|
publishers.forEach(p => p.currentWeekAssignments = 0);
|
|
}
|
|
if (forDay) break;
|
|
}
|
|
|
|
let allShifts = await prisma.shift.findMany({
|
|
where: {
|
|
startTime: {
|
|
gte: monthInfo.firstMonday,
|
|
lt: monthInfo.lastSunday,
|
|
},
|
|
},
|
|
include: {
|
|
assignments: {
|
|
include: {
|
|
publisher: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
let publishersToday = [];
|
|
let rankedPublishers = [];
|
|
|
|
// Second pass - prioritize shifts with transport where it is needed
|
|
console.log(" second pass - fix transports " + monthInfo.monthName + " " + monthInfo.year);
|
|
day = new Date(monthInfo.firstMonday);
|
|
dayNr = 1;
|
|
weekNr = 1;
|
|
while (day < endDate) {
|
|
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day);
|
|
let event = events.find((event) => event.dayofweek == common.DaysOfWeekArray[day.getDayEuropean()]);
|
|
if (event) {
|
|
let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false);
|
|
let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day));
|
|
|
|
let publishersToday = await prisma.assignment.findMany({
|
|
where: {
|
|
shift: {
|
|
startTime: {
|
|
gte: common.getStartOfDay(day),
|
|
lt: common.getEndOfDay(day),
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
publisherId: true,
|
|
},
|
|
}).then((assignments) => assignments.map(a => a.publisherId));
|
|
|
|
let transportShifts = shifts.filter(s => s.requiresTransport);
|
|
transportShifts[0].transportIn = true;
|
|
if (transportShifts.length > 1) {
|
|
transportShifts[1].transportOut = true;
|
|
}
|
|
// if there are no transport yet:
|
|
// transportShifts.forEach(async shift => {
|
|
for (const shift of transportShifts) {
|
|
let publishersNeeded = event.numberOfPublishers - shift.assignments.length;
|
|
let transportCapable = shift.assignments.filter(a => a.isWithTransport);
|
|
let tramsportCapableMen = transportCapable.filter(a => a.publisher.isMale);
|
|
let mayNeedTransport = transportCapable.length < 2 && tramsportCapableMen.length < 1;
|
|
|
|
if (!mayNeedTransport) {
|
|
console.log("shift " + shift.name + " has transport (" + transportCapable.length + " transport capable)");
|
|
}
|
|
else if (publishersNeeded > 0) {
|
|
console.log("shift " + shift.name + " requires transport (" + transportCapable.length + " transport capable)");
|
|
|
|
let availablePubsForTheShift = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', shift.startTime, true, false, false, true, false);
|
|
|
|
let availablePublishers = availablePubsForTheShift.filter(p => {
|
|
const hasTransportInAvailability = shift.transportIn && p.availabilities.some(avail => avail.isWithTransportIn);
|
|
const hasTransportOutAvailability = shift.transportOut && p.availabilities.some(avail => avail.isWithTransportOut);
|
|
|
|
return (hasTransportInAvailability || hasTransportOutAvailability);
|
|
});
|
|
|
|
availablePublishers = await FilterInappropriatePublishers([...availablePublishers], publishersToday, shift);
|
|
if (algType == 0) {
|
|
rankedPublishers = await RankPublishersForShiftOld([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr);
|
|
} else if (algType == 1) {
|
|
rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr);
|
|
}
|
|
if (rankedPublishers.length > 0) {
|
|
const newAssignment = await prisma.assignment.create({
|
|
data: {
|
|
shift: {
|
|
connect: {
|
|
id: shift.id,
|
|
},
|
|
},
|
|
publisher: {
|
|
connect: {
|
|
id: rankedPublishers[0].id,
|
|
},
|
|
},
|
|
isWithTransport: true,
|
|
isConfirmed: true,
|
|
isBySystem: false,
|
|
},
|
|
});
|
|
|
|
shift.assignments.push(newAssignment);
|
|
publishersToday.push(rankedPublishers[0].id);
|
|
updateRegistry(rankedPublishers[0].id, day, weekNr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
day.setDate(day.getDate() + 1);
|
|
}
|
|
|
|
// Fill the rest of the shifts
|
|
let goal = 1;
|
|
while (goal <= 4) {
|
|
console.log("#".repeat(50));
|
|
console.log("Filling shifts with " + goal + " publishers " + monthInfo.monthName + " " + monthInfo.year);
|
|
day = new Date(monthInfo.firstMonday);
|
|
dayNr = 1;
|
|
weekNr = 1;
|
|
while (day < endDate) {
|
|
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day);
|
|
let event = events.find((event) => event.dayofweek == common.DaysOfWeekArray[day.getDayEuropean()]);
|
|
if (event) {
|
|
let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, false);
|
|
let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day));
|
|
let publishersToday = await prisma.assignment.findMany({
|
|
where: {
|
|
shift: {
|
|
startTime: {
|
|
gte: common.getStartOfDay(day),
|
|
lt: common.getEndOfDay(day),
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
publisherId: true,
|
|
},
|
|
}).then((assignments) => assignments.map(a => a.publisherId));
|
|
|
|
let shiftsToFill = shifts.filter(s => s.assignments.length < goal);
|
|
console.log("" + day.toLocaleDateString() + " " + shiftsToFill.length + " shifts with less than " + goal + " publishers");
|
|
|
|
for (const shift of shiftsToFill) {
|
|
console.log("Filling shift " + shift.name + " with " + goal + " publishers");
|
|
let publishersNeeded = event.numberOfPublishers - shift.assignments.length;
|
|
if (publishersNeeded > 0 && shift.assignments.length < goal) {
|
|
let availablePubsForTheShift = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', shift.startTime, true, false, false, true, false);
|
|
|
|
let availablePublishers = await FilterInappropriatePublishers([...availablePubsForTheShift], publishersToday, shift);
|
|
if (algType == 0) {
|
|
rankedPublishers = await RankPublishersForShiftOld([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr);
|
|
} else if (algType == 1) {
|
|
rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr);
|
|
}
|
|
|
|
if (rankedPublishers.length == 0) {
|
|
console.log("No available publishers for shift " + shift.name);
|
|
} else if (rankedPublishers.length > 0) {
|
|
console.log("Assigning " + rankedPublishers[0].firstName + " " + rankedPublishers[0].lastName + " to " + new Date(shift.startTime).getDate() + " " + shift.name);
|
|
const newAssignment = await prisma.assignment.create({
|
|
data: {
|
|
shift: {
|
|
connect: {
|
|
id: shift.id,
|
|
},
|
|
},
|
|
publisher: {
|
|
connect: {
|
|
id: rankedPublishers[0].id,
|
|
},
|
|
},
|
|
isConfirmed: true,
|
|
isBySystem: false,
|
|
},
|
|
});
|
|
shift.assignments.push(newAssignment);
|
|
publishersToday.push(rankedPublishers[0].id);
|
|
updateRegistry(rankedPublishers[0].id, day, weekNr);
|
|
|
|
let familyMembers = availablePubsForTheShift.filter(p => p.familyHeadId && p.familyHeadId === rankedPublishers[0].familyHeadId);
|
|
if (familyMembers.length > 0) {
|
|
familyMembers.forEach(async familyMember => {
|
|
if (shift.assignments.length < event.numberOfPublishers) {
|
|
console.log("Assigning " + familyMember.firstName + " " + familyMember.lastName + " to " + shift.startDate.getDate() + " " + shift.name);
|
|
const newAssignment = await prisma.assignment.create({
|
|
data: {
|
|
shift: {
|
|
connect: {
|
|
id: shift.id,
|
|
},
|
|
},
|
|
publisher: {
|
|
connect: {
|
|
id: familyMember.id,
|
|
},
|
|
},
|
|
isConfirmed: true,
|
|
isBySystem: false,
|
|
},
|
|
});
|
|
shift.assignments.push(newAssignment);
|
|
publishersToday.push(familyMember.id);
|
|
updateRegistry(familyMember.id, day, weekNr);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
day.setDate(day.getDate() + 1);
|
|
}
|
|
goal += 1;
|
|
}
|
|
|
|
if (!forDay) {
|
|
console.log("###############################################");
|
|
console.log(" DONE CREATING SCHEDULE FOR " + monthInfo.monthName + " " + monthInfo.year);
|
|
console.log("###############################################");
|
|
}
|
|
|
|
return {};
|
|
} catch (error) {
|
|
console.log(error);
|
|
return { error: error };
|
|
}
|
|
}
|
|
|
|
async function FilterInappropriatePublishers(availablePublishers, pubsToExclude, shift) {
|
|
//ToDo: Optimization: store number of publishers, so we process the shifts from least to most available publishers later.
|
|
let goodPublishers = availablePublishers.filter(p => {
|
|
const isNotAssigned = !shift.assignments.some(a => a.publisher?.id === p.id);
|
|
const isNotAssignedToday = !pubsToExclude.includes(p.id);
|
|
const isAssignedEnough = p.currentMonthAssignments >= p.desiredShiftsPerMonth;
|
|
return isNotAssigned && isNotAssignedToday && !isAssignedEnough;
|
|
});
|
|
return goodPublishers;
|
|
}
|
|
|
|
//General guidelines affecting ranking of publishers for shift assignment
|
|
// 0. generate shifts and assign publishers from the previous month if still available
|
|
// 1. Make sure we always put people only when they are available.
|
|
// 2. First provision one male or two females that are available for transport in the first and last shifts.
|
|
// 3, Then gradually fill all other shifts with day by day troughout the whole month (monthInfo.firstMonday to .lastSunday) with first one, then two, then 3 and wherever possible more (up to CartEvent.numberOfPublishers number)
|
|
// 4. Some publishers are available only at specific time (somoetimes only once) and other are more available. if people are available only for this time, prioritize them so they are not left behind.
|
|
// 5. prioritize based on publisher's desiredShiftsPerMonth and previous months assignments.
|
|
// 6. Idealy noone should be more than once a week. disqualify publishers already on a shift this week. only assign them if there are no other options and we have less than 3 publishers on a specific shift.
|
|
|
|
//sort publishers to rank the best option for the current shift assignment
|
|
async function RankPublishersForShiftOld(publishers, scheduledPubsPerDayAndWeek, currentDay) {
|
|
publishers.forEach(p => {
|
|
p.DesiredMinusCurrent = p.desiredShiftsPerMonth - p.currentMonthAssignments;
|
|
});
|
|
|
|
let ranked = publishers.sort((a, b) => {
|
|
// males first (descending)
|
|
if (a.isMale && !b.isMale) return -1;
|
|
|
|
// desired completion (normalized 0%=0 - 100%=1); lower first
|
|
const desiredCompletionA = a.currentMonthAssignments / a.desiredShiftsPerMonth;
|
|
const desiredCompletionB = b.currentMonthAssignments / b.desiredShiftsPerMonth;
|
|
|
|
let adjustedCompletionA = desiredCompletionA;
|
|
let adjustedCompletionB = desiredCompletionB;
|
|
|
|
// Apply penalties based on proximity to current day
|
|
for (let i = 1; i <= 6; i++) {
|
|
const previousDayKey = common.getISODateOnly(addDays(currentDay, -i));
|
|
const nextDayKey = common.getISODateOnly(addDays(currentDay, i));
|
|
|
|
const penalty = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95][i - 1]; // Penalties for +-1 to +-6 days
|
|
if (flattenRegistry(previousDayKey).includes(a.id)) {
|
|
adjustedCompletionA *= penalty;
|
|
}
|
|
if (flattenRegistry(previousDayKey).includes(b.id)) {
|
|
adjustedCompletionB *= penalty;
|
|
}
|
|
if (flattenRegistry(nextDayKey).includes(a.id)) {
|
|
adjustedCompletionA *= penalty;
|
|
}
|
|
if (flattenRegistry(nextDayKey).includes(b.id)) {
|
|
adjustedCompletionB *= penalty;
|
|
}
|
|
|
|
}
|
|
|
|
const desiredCompletionDiff = adjustedCompletionA - adjustedCompletionB;
|
|
if (desiredCompletionDiff !== 0) return desiredCompletionDiff;
|
|
|
|
// less available first (ascending)
|
|
const availabilityDifference = a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount;
|
|
if (availabilityDifference !== 0) return availabilityDifference;
|
|
|
|
// less assigned first (ascending)
|
|
return a.currentMonthAssignments - b.currentMonthAssignments;
|
|
});
|
|
|
|
return ranked;
|
|
}
|
|
|
|
|
|
// ToDo: add negative weights for currentweekAssignments, so we avoid assigning the same publishers multiple times in a week. having in mind the days difference between shifts.
|
|
async function RankPublishersForShiftWeighted(publishers, scheduledPubsPerDayAndWeek, currentDay, currentWeekNr) {
|
|
// Define weights for each criterion
|
|
const weights = {
|
|
gender: 2,
|
|
desiredCompletion: 3,
|
|
availability: 2,
|
|
lastMonthCompletion: 3,
|
|
currentAssignments: 1
|
|
};
|
|
|
|
// Normalize weights to ensure they sum to 1
|
|
const totalWeight = Object.values(weights).reduce((acc, val) => acc + val, 0);
|
|
Object.keys(weights).forEach(key => {
|
|
weights[key] /= totalWeight;
|
|
});
|
|
|
|
publishers.forEach(p => {
|
|
p.lastMonthCompletion = p.previousMonthAssignments / p.currentMonthAssignments;
|
|
p.desiredCompletion = p.currentMonthAssignments / p.desiredShiftsPerMonth;
|
|
});
|
|
|
|
const calculateScoreAndPenalties = (p) => {
|
|
let score = (p.isMale ? weights.gender : 0) -
|
|
(p.desiredCompletion * weights.desiredCompletion) +
|
|
((1 - p.currentMonthAvailabilityHoursCount / 24) * weights.availability) +
|
|
(p.currentMonthAssignments * weights.lastMonthCompletion) -
|
|
(p.currentMonthAssignments * weights.currentAssignments);
|
|
|
|
let penalties = [];
|
|
|
|
// Apply penalties based on proximity to current day
|
|
for (let i = 1; i <= 6; i++) {
|
|
const previousDayKey = common.getISODateOnly(addDays(currentDay, -i));
|
|
const nextDayKey = common.getISODateOnly(addDays(currentDay, i));
|
|
|
|
const penalty = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95][i - 1]; // Penalties for +-1 to +-6 days
|
|
|
|
if (flattenRegistry(previousDayKey).includes(p.id)) {
|
|
score *= penalty;
|
|
penalties.push({ day: previousDayKey, penalty });
|
|
}
|
|
if (flattenRegistry(nextDayKey).includes(p.id)) {
|
|
score *= penalty;
|
|
penalties.push({ day: nextDayKey, penalty });
|
|
}
|
|
}
|
|
return { score, penalties };
|
|
};
|
|
|
|
// Calculate scores and penalties for each publisher
|
|
publishers.forEach(p => {
|
|
const result = calculateScoreAndPenalties(p);
|
|
p.score = result.score;
|
|
p.penalties = result.penalties;
|
|
});
|
|
|
|
// Sort publishers based on score
|
|
let ranked = publishers.sort((a, b) => b.score - a.score);
|
|
|
|
// Log the scores and penalties of the top publisher
|
|
if (ranked.length > 0) {
|
|
console.log(`Top Publisher: ${ranked[0].firstName} ${ranked[0].lastName}`,
|
|
` Score: ${ranked[0].score}`, "last score: ", ranked[ranked.length - 1].score,
|
|
` Penalties: `, ranked[0].penalties);
|
|
}
|
|
|
|
return ranked;
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function DeleteShiftsForMonth(monthInfo) {
|
|
try {
|
|
const prisma = common.getPrismaClient();
|
|
await prisma.shift.deleteMany({
|
|
where: {
|
|
startTime: {
|
|
gte: monthInfo.firstMonday,
|
|
lt: monthInfo.lastSunday,
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
}
|
|
|
|
async function DeleteShiftsForDay(date) {
|
|
const prisma = common.getPrismaClient();
|
|
try {
|
|
// Assuming shifts do not span multiple days, so equality comparison is used
|
|
await prisma.shift.deleteMany({
|
|
where: {
|
|
startTime: {
|
|
gte: date,
|
|
lt: new Date(date.getTime() + 86400000), // +1 day in milliseconds
|
|
},
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
}
|
|
|
|
async function getShiftsFromLastMonth(monthInfo) {
|
|
const prisma = common.getPrismaClient();
|
|
// Fetch shifts for the month
|
|
const rawShifts = await prisma.shift.findMany({
|
|
where: {
|
|
startTime: {
|
|
gte: monthInfo.firstMonday,
|
|
lte: monthInfo.lastSunday,
|
|
},
|
|
},
|
|
include: {
|
|
assignments: {
|
|
include: {
|
|
publisher: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Process shifts to add weekNr and shiftNr
|
|
return rawShifts.map(shift => ({
|
|
...shift,
|
|
weekNr: common.getWeekNumber(new Date(shift.startTime)),
|
|
shiftNr: rawShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(shift.startTime)).indexOf(shift) + 1,
|
|
weekDay: common.DaysOfWeekArray[new Date(shift.startTime).getDayEuropean()],
|
|
}));
|
|
}
|
|
|
|
function findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr) {
|
|
let weekDay = common.DaysOfWeekArray[day.getDayEuropean()];
|
|
return shiftsLastMonth.find(s => {
|
|
return s.weekNr === weekNr &&
|
|
s.shiftNr === shiftNr &&
|
|
s.weekDay === weekDay;
|
|
});
|
|
}
|
|
|
|
//ToDo use bulk find instead of loop
|
|
// async function getAvailablePublishersForShift(startTime, endTime, allPublishers, publishersThisWeek) {
|
|
// let availablePublishers = [];
|
|
|
|
// for (let publisher of allPublishers) {
|
|
// let availability = await FindPublisherAvailability(publisher.id, startTime, endTime);
|
|
|
|
// if (availability && !publishersThisWeek.includes(publisher.id)) {
|
|
// availablePublishers.push(publisher);
|
|
// }
|
|
// }
|
|
|
|
// return availablePublishers;
|
|
// }
|
|
|
|
async function getAvailablePublishersForShiftNew(startTime, endTime, allPublishers, publishersThisWeek) {
|
|
let availablePublishers = [];
|
|
//let availablePubsForTheDay = await data.filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', startTime, true, false, false, true, false);
|
|
for (let publisher of allPublishers) {
|
|
const isAvailableForShift = publisher.availabilities.some(avail =>
|
|
avail.startTime <= startTime
|
|
&& avail.endTime >= endTime
|
|
);
|
|
|
|
if (isAvailableForShift && !publishersThisWeek.includes(publisher.id)) {
|
|
availablePublishers.push(publisher);
|
|
}
|
|
}
|
|
|
|
return availablePublishers;
|
|
}
|
|
|
|
async function FindPublisherAvailability(publisherId, startDate, endDate, dayOfWeekEnum, weekNr) {
|
|
const prisma = common.getPrismaClient();
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
const hours = start.getHours();
|
|
const minutes = start.getMinutes();
|
|
|
|
const exactAvailabilities = await prisma.availability.findMany({
|
|
where: {
|
|
publisherId: publisherId,
|
|
// type: AvailabilityType.OneTime,
|
|
AND: [ // Ensure both conditions must be met
|
|
{ startTime: { lte: start } }, // startTime is less than or equal to the date
|
|
{ endTime: { gte: end } },// endTime is greater than or equal to the date
|
|
],
|
|
}
|
|
});
|
|
|
|
|
|
// Query for repeating availabilities, ignoring exact date, focusing on time and day of week/month
|
|
let repeatingAvailabilities = await prisma.availability.findMany({
|
|
where: {
|
|
publisherId: publisherId,
|
|
dayOfMonth: null, // This signifies a repeating availability
|
|
OR: [
|
|
{ dayofweek: dayOfWeekEnum },// Matches the specific day of the week
|
|
{ weekOfMonth: weekNr } // Matches specific weeks of the month
|
|
]
|
|
}
|
|
});
|
|
//filter out availabilities that does not match the time
|
|
// repeatingAvailabilities = repeatingAvailabilities.filter(avail => {
|
|
// return avail.startTime.getHours() <= hours && avail.endTime.getHours() >= hours
|
|
// && avail.startTime.getMinutes() <= minutes && avail.endTime.getMinutes() >= minutes
|
|
// && avail.startTime <= new Date(startDate) && (endDate ? avail.endTime >= new Date(endDate) : true)
|
|
// });
|
|
|
|
repeatingAvailabilities = repeatingAvailabilities.filter(avail => {
|
|
const availStart = new Date(avail.startTime);
|
|
const availEnd = new Date(avail.endTime);
|
|
const availUntil = avail.endDate ? new Date(avail.endDate) : null;
|
|
|
|
const availStartTimeInt = common.timeToInteger(availStart.getHours(), availStart.getMinutes());
|
|
const availEndTimeInt = common.timeToInteger(availEnd.getHours(), availEnd.getMinutes());
|
|
const startTimeInt = common.timeToInteger(start.getHours(), start.getMinutes());
|
|
const endTimeInt = common.timeToInteger(end.getHours(), end.getMinutes());
|
|
|
|
const isValid = availStartTimeInt <= startTimeInt && availEndTimeInt >= endTimeInt
|
|
&& availStart <= start
|
|
&& (!availUntil || availUntil >= end);
|
|
|
|
return isValid;
|
|
});
|
|
|
|
// return [...exactAvailabilities, ...repeatingAvailabilities];
|
|
// Combine the exact and repeating availabilities, return first or null if no availabilities are found
|
|
return exactAvailabilities.length > 0 ? exactAvailabilities[0] : repeatingAvailabilities.length > 0 ? repeatingAvailabilities[0] : null;
|
|
}
|
|
|
|
// ### COPIED TO shift api (--) ###
|
|
|
|
// function addAssignmentToPublisher(shiftAssignments: any[], publisher: Publisher) {
|
|
// shiftAssignments.push({ publisherId: publisher.id });
|
|
// publisher.currentWeekAssignments++ || 1;
|
|
// publisher.currentDayAssignments++ || 1;
|
|
// publisher.currentMonthAssignments++ || 1;
|
|
// //console.log(`manual assignment: ${dayName} ${dayOfM} ${shiftStart}:${shiftEnd} ${p.firstName} ${p.lastName} ${p.availabilityIndex} ${p.currentMonthAssignments}`);
|
|
// console.log(`manual assignment: ${publisher.firstName} ${publisher.lastName} ${publisher.currentMonthAssignments}`);
|
|
// return publisher;
|
|
// }
|
|
|
|
|
|
|
|
/**
|
|
* Dangerous function that deletes all shifts and publishers.
|
|
* @param date
|
|
* @returns
|
|
*/
|
|
async function DeleteSchedule(axios: Axios, date: Date, forDay: Boolean | undefined) {
|
|
try {
|
|
let monthInfo = common.getMonthDatesInfo(new Date(date));
|
|
if (forDay) {
|
|
// Delete shifts only for the specific day
|
|
await data.DeleteShiftsForDay(monthInfo.date);
|
|
} else {
|
|
// Delete all shifts for the entire month
|
|
await data.DeleteShiftsForMonth(monthInfo);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(error);
|
|
return { error: error };
|
|
}
|
|
}
|
|
|
|
async function CreateCalendarForUser(eventId: string | string[] | undefined) {
|
|
try {
|
|
//CAL.authorizeNew();
|
|
CAL.createEvent(eventId);
|
|
} catch (error) {
|
|
console.log(error);
|
|
return { error: error };
|
|
}
|
|
}
|
|
|
|
/*
|
|
obsolete?
|
|
*/
|
|
async function ImportShiftsFromDocx(axios: Axios) {
|
|
try {
|
|
const { data: shifts } = await axios.get(`/api/data/shifts`);
|
|
shifts.forEach(async (shift: { id: any; }) => {
|
|
await axios.delete(`/api/data/shifts/${shift.id}`);
|
|
});
|
|
const { data: shiftsToCreate } = await axios.get(`/api/data/shiftsToCreate`);
|
|
shiftsToCreate.forEach(async (shift: any) => {
|
|
await axios.post(`/api/data/shifts`, shift);
|
|
});
|
|
} catch (error) {
|
|
console.log(error);
|
|
return { error: error };
|
|
}
|
|
}
|
|
|
|
|
|
async function GenerateOptimalSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay) {
|
|
const prisma = common.getPrismaClient();
|
|
try {
|
|
const monthInfo = common.getMonthDatesInfo(new Date(date));
|
|
const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1));
|
|
|
|
if (forDay) {
|
|
await DeleteShiftsForDay(monthInfo.date);
|
|
} else {
|
|
await DeleteShiftsForMonth(monthInfo);
|
|
}
|
|
|
|
const events = await prisma.cartEvent.findMany({
|
|
where: {
|
|
isActive: true
|
|
}
|
|
});
|
|
let shiftsLastMonth = await getShiftsFromLastMonth(lastMonthInfo);
|
|
let publishers = await data.getAllPublishersWithStatisticsMonth(date, false, false);
|
|
|
|
let day = new Date(monthInfo.firstMonday);
|
|
let endDate = monthInfo.lastSunday;
|
|
let weekNr = 1;
|
|
|
|
if (forDay) {
|
|
day = monthInfo.date;
|
|
endDate = new Date(monthInfo.date.getTime() + 86400000); // +1 day
|
|
weekNr = common.getWeekNumber(monthInfo.date);
|
|
}
|
|
|
|
let allShifts = [];
|
|
|
|
// First pass: Generate shifts and copy assignments from the previous month
|
|
while (day < endDate) {
|
|
let dayShifts = await generateShiftsForDay(day, events, shiftsLastMonth, weekNr, copyFromPreviousMonth);
|
|
allShifts = [...allShifts, ...dayShifts];
|
|
|
|
day.setDate(day.getDate() + 1);
|
|
if (common.DaysOfWeekArray[day.getDayEuropean()] === DayOfWeek.Sunday) {
|
|
weekNr++;
|
|
}
|
|
if (forDay) break;
|
|
}
|
|
|
|
// Second pass: Optimize assignments
|
|
allShifts = await optimizeAssignments(allShifts, publishers, events);
|
|
|
|
// Save optimized shifts to the database
|
|
for (let shift of allShifts) {
|
|
await saveShiftToDB(shift);
|
|
}
|
|
|
|
return {};
|
|
} catch (error) {
|
|
console.log(error);
|
|
return { error: error };
|
|
}
|
|
}
|
|
|
|
async function generateShiftsForDay(day, events, shiftsLastMonth, weekNr, copyFromPreviousMonth) {
|
|
const prisma = common.getPrismaClient();
|
|
let dayShifts = [];
|
|
let dayOfWeekEnum = common.getDayOfWeekNameEnEnumForDate(day);
|
|
let dayName = common.DaysOfWeekArray[day.getDayEuropean()];
|
|
const event = events.find((event) => event.dayofweek == dayName && (event.dayOfMonth == null || event.dayOfMonth == day.getDate()));
|
|
|
|
if (!event) return dayShifts;
|
|
|
|
let startTime = new Date(day);
|
|
startTime.setHours(event.startTime.getHours(), event.startTime.getMinutes());
|
|
let endTime = new Date(day);
|
|
endTime.setHours(event.endTime.getHours(), event.endTime.getMinutes());
|
|
|
|
let shiftStart = new Date(startTime);
|
|
let shiftEnd = new Date(startTime);
|
|
shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration);
|
|
|
|
let shiftNr = 0;
|
|
while (shiftEnd <= endTime) {
|
|
shiftNr++;
|
|
let isTransportRequired = shiftNr == 1 || shiftEnd.getTime() == endTime.getTime();
|
|
|
|
let shift = {
|
|
startTime: new Date(shiftStart),
|
|
endTime: new Date(shiftEnd),
|
|
name: `${event.dayofweek} ${shiftStart.toLocaleTimeString()} - ${shiftEnd.toLocaleTimeString()}`,
|
|
requiresTransport: isTransportRequired,
|
|
cartEventId: event.id,
|
|
assignments: [],
|
|
};
|
|
|
|
if (copyFromPreviousMonth) {
|
|
const shiftLastMonthSameDay = findTheSameShiftFromLastMonth(shiftsLastMonth, day, weekNr, shiftNr);
|
|
if (shiftLastMonthSameDay) {
|
|
shift.assignments = shiftLastMonthSameDay.assignments
|
|
.map(a => ({
|
|
publisherId: a.publisher.id,
|
|
isConfirmed: true,
|
|
isWithTransport: a.isWithTransport
|
|
}));
|
|
}
|
|
}
|
|
|
|
dayShifts.push(shift);
|
|
|
|
shiftStart = new Date(shiftEnd);
|
|
shiftEnd.setMinutes(shiftStart.getMinutes() + event.shiftDuration);
|
|
}
|
|
|
|
return dayShifts;
|
|
}
|
|
|
|
async function optimizeAssignments(allShifts, publishers, events) {
|
|
let scheduledPubsPerDayAndWeek = {};
|
|
|
|
for (let shift of allShifts) {
|
|
const event = events.find(e => e.id === shift.cartEventId);
|
|
const day = new Date(shift.startTime);
|
|
const weekNr = common.getWeekNumber(day);
|
|
|
|
let availablePubs = await getAvailablePublishersForShiftNew(shift.startTime, shift.endTime, publishers, []);
|
|
availablePubs = filterPublishersForShift(availablePubs, shift, scheduledPubsPerDayAndWeek, day, weekNr);
|
|
|
|
while (shift.assignments.length < event.numberOfPublishers && availablePubs.length > 0) {
|
|
const rankedPubs = rankPublishersForShift(availablePubs, scheduledPubsPerDayAndWeek, day, weekNr);
|
|
const selectedPub = rankedPubs[0];
|
|
|
|
shift.assignments.push({
|
|
publisherId: selectedPub.id,
|
|
isConfirmed: true,
|
|
isWithTransport: shift.requiresTransport && (selectedPub.isWithTransportIn || selectedPub.isWithTransportOut)
|
|
});
|
|
|
|
updateRegistry(selectedPub.id, day, weekNr, scheduledPubsPerDayAndWeek);
|
|
selectedPub.currentMonthAssignments++;
|
|
|
|
availablePubs = availablePubs.filter(p => p.id !== selectedPub.id);
|
|
}
|
|
}
|
|
|
|
return allShifts;
|
|
}
|
|
|
|
function filterPublishersForShift(publishers, shift, scheduledPubsPerDayAndWeek, day, weekNr) {
|
|
const dayKey = common.getISODateOnly(day);
|
|
return publishers.filter(p => {
|
|
const isNotAssigned = !shift.assignments.some(a => a.publisherId === p.id);
|
|
const isNotAssignedToday = !flattenRegistry(scheduledPubsPerDayAndWeek[dayKey]).includes(p.id);
|
|
const isNotOverAssigned = p.currentMonthAssignments < p.desiredShiftsPerMonth;
|
|
const isNotAssignedThisWeek = !Object.keys(scheduledPubsPerDayAndWeek)
|
|
.filter(key => common.getWeekNumber(new Date(key)) === weekNr)
|
|
.some(key => flattenRegistry(scheduledPubsPerDayAndWeek[key]).includes(p.id));
|
|
|
|
return isNotAssigned && isNotAssignedToday && isNotOverAssigned && isNotAssignedThisWeek;
|
|
});
|
|
}
|
|
|
|
function rankPublishersForShift(publishers, scheduledPubsPerDayAndWeek, currentDay, currentWeekNr) {
|
|
const weights = {
|
|
gender: 2,
|
|
desiredCompletion: 3,
|
|
availability: 2,
|
|
lastMonthCompletion: 3,
|
|
currentAssignments: 1
|
|
};
|
|
|
|
const totalWeight = Object.values(weights).reduce((acc, val) => acc + val, 0);
|
|
Object.keys(weights).forEach(key => {
|
|
weights[key] /= totalWeight;
|
|
});
|
|
|
|
publishers.forEach(p => {
|
|
p.score = calculatePublisherScore(p, weights, scheduledPubsPerDayAndWeek, currentDay, currentWeekNr);
|
|
});
|
|
|
|
return publishers.sort((a, b) => b.score - a.score);
|
|
}
|
|
|
|
function calculatePublisherScore(publisher, weights, scheduledPubsPerDayAndWeek, currentDay, currentWeekNr) {
|
|
let score = (publisher.isMale ? weights.gender : 0) -
|
|
((publisher.currentMonthAssignments / publisher.desiredShiftsPerMonth) * weights.desiredCompletion) +
|
|
((1 - publisher.currentMonthAvailabilityHoursCount / 24) * weights.availability) +
|
|
((publisher.previousMonthAssignments / publisher.currentMonthAssignments) * weights.lastMonthCompletion) -
|
|
(publisher.currentMonthAssignments * weights.currentAssignments);
|
|
|
|
// Apply penalties for nearby assignments
|
|
for (let i = 1; i <= 6; i++) {
|
|
const previousDayKey = common.getISODateOnly(addDays(currentDay, -i));
|
|
const nextDayKey = common.getISODateOnly(addDays(currentDay, i));
|
|
const penalty = [0.5, 0.7, 0.8, 0.85, 0.9, 0.95][i - 1];
|
|
|
|
if (flattenRegistry(scheduledPubsPerDayAndWeek[previousDayKey]).includes(publisher.id) ||
|
|
flattenRegistry(scheduledPubsPerDayAndWeek[nextDayKey]).includes(publisher.id)) {
|
|
score *= penalty;
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
async function saveShiftToDB(shift) {
|
|
const prisma = common.getPrismaClient();
|
|
await prisma.shift.create({
|
|
data: {
|
|
startTime: shift.startTime,
|
|
endTime: shift.endTime,
|
|
name: shift.name,
|
|
requiresTransport: shift.requiresTransport,
|
|
cartEvent: {
|
|
connect: {
|
|
id: shift.cartEventId,
|
|
},
|
|
},
|
|
assignments: {
|
|
create: shift.assignments.map(a => ({
|
|
publisher: {
|
|
connect: { id: a.publisherId }
|
|
},
|
|
isWithTransport: a.isWithTransport,
|
|
isConfirmed: a.isConfirmed,
|
|
isBySystem: true,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|