Files
mwitnessing/pages/api/shiftgenerate.ts
2024-09-26 02:18:45 +03:00

1140 lines
43 KiB
TypeScript

//import { getToken } from "next-auth/jwt";
const fs = require('fs');
const path = require('path');
import axiosServer from '../../src/axiosServer';
import { getToken } from "next-auth/jwt";
import { set, format, isBefore, addDays, addMinutes, isAfter, isEqual, getHours, getMinutes, getSeconds } 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);
var result = await GenerateScheduleNew(axios, date, copyFromPreviousMonth, autoFill, forDay, type);
}
else {
// Create a new log file with a unique name
const logFileName = `shift_generate_log_${Date.now()}.txt`;
const logFilePath = path.join(process.cwd(), logFileName);
// Override console.log to write to the log file
const originalConsoleLog = console.log;
console.log = function (message) {
fs.appendFileSync(logFilePath, message + '\n');
originalConsoleLog.apply(console, arguments);
};
var result = await GenerateSchedule(axios, date, copyFromPreviousMonth, autoFill, forDay, type);
// Restore the original console.log
console.log = originalConsoleLog;
}
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, until) {
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 data.DeleteShiftsForDay(monthInfo.date);
} else {
await data.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;
if (until === undefined) {
const oneDayInMs = 24 * 60 * 60 * 1000;
endDate = new Date(monthInfo.date.getTime() + oneDayInMs);
}
else {
endDate = new Date(until);
}
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;
let availability = await data.FindPublisherAvailability(publisher.id, shiftStart, shiftEnd, dayOfWeekEnum, weekNr);
if (availability) {
console.log("AVAILABLE: " + publisher.firstName + " " + publisher.lastName + (assignment.originalPublisher ? " (original)" : "") + " - av:" + availability?.id + ": " + common.getDateFormattedShort(availability?.startTime) + " " + common.getTimeFormatted(availability?.startTime) + " - " + common.getTimeFormatted(availability?.endTime));
} else {
console.log(" " + publisher.firstName + " " + publisher.lastName + (assignment.originalPublisher ? " (original)" : "") + " - NOT AVAILABLE");
}
//---------------------------------------------------
// // COMMENT TO DISABLE COPY FROM LAST MONTH
if (availability && copyFromPreviousMonth && !publishersThisWeek.includes(publisher.id)) {
const transportCount = shiftAssignments.filter(a => a.isWithTransport).length;
const isWithTransport = availability.isWithTransportIn || availability.isWithTransportOut;
if (!isWithTransport || transportCount < 2) {
shiftAssignments.push({
publisherId: publisher.id,
isConfirmed: true,
isBySystem: true,
isWithTransport: isWithTransport
});
publishersThisWeek.push(publisher.id);
updateRegistry(publisher.id, day, weekNr);
}
else {
console.log(" " + publisher.firstName + " " + publisher.lastName + " skipped (transport already assigned)");
}
}
//---------------------------------------------------
}
}
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);
}
if (forDay) { break; }
else {
day.setDate(day.getDate() + 1);
dayNr++;
if (common.DaysOfWeekArray[day.getDayEuropean()] === DayOfWeek.Sunday) {
weekNr++;
publishersThisWeek = [];
publishers.forEach(p => p.currentWeekAssignments = 0);
}
}
}
let from = monthInfo.firstMonday, to = monthInfo.lastSunday;
if (forDay) {
from = day;
to = endDate;
}
let allShifts = await prisma.shift.findMany({
where: {
startTime: {
gte: from,
lt: to,
},
},
include: {
assignments: {
include: {
publisher: true,
},
},
},
});
let publishersToday = [];
let rankedPublishers = [];
// # # # # # # # # # # #
// Second pass - prioritize shifts with transport where it is needed
if (forDay) { }
else {
day = new Date(monthInfo.firstMonday);
dayNr = 1;
weekNr = 1;
}
console.log("\r\n\r\n\r\n" + "# ".repeat(50));
console.log("Second pass - fix transports " + day.toLocaleDateString());
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,familyHeadId', 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);
}
AddPublisherAssignment(prisma, event, shift, availablePublishers, rankedPublishers, publishersToday, day, weekNr);
}
}
}
if (forDay) { break; }
else {
day.setDate(day.getDate() + 1);
}
}
// # # # # # # # # # # #
// 3. Fill the rest of the shifts
let goal = 1;
while (goal <= 4) {
if (forDay) {
}
else {
day = new Date(monthInfo.firstMonday);
dayNr = 1;
weekNr = 1;
}
console.log("\r\n\r\n" + "# ".repeat(50));
console.log("Filling shifts with " + goal + " publishers | " + day.toLocaleDateString());
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");
//when adding new assignment, first check if there are family members available. If yes, olnly proceed adding all available family members at once if there are enough free slots. ignore the main publisher if he/she can]t be with his family and search for the next best candidate
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,familyHeadId', shift.startTime, true, false, false, true, false);
let availablePublishers = await FilterInappropriatePublishers([...availablePubsForTheShift], publishersToday, shift);
if (algType == 0) {
rankedPublishers = await RankPublishersForShiftOld([...availablePublishers], scheduledPubsPerDayAndWeek, day);
} else if (algType == 1) {
rankedPublishers = await RankPublishersForShiftWeighted([...availablePublishers], scheduledPubsPerDayAndWeek, day, weekNr);
}
await AddPublisherAssignment(prisma, event, shift, availablePublishers, rankedPublishers, publishersToday, day, weekNr);
}
}
}
if (forDay) { break; }
else {
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 AddPublisherAssignment(prisma, event, shift, availablePubsForTheShift, rankedPublishers, publishersToday, day, weekNr) {
if (rankedPublishers.length == 0) {
console.log("! ! ! No available publishers for shift " + shift.name + " ! ! !");
} else {
for (let i = 0; i < rankedPublishers.length; i++) {
let mainPublisher = rankedPublishers[i];
let familyMembers = availablePubsForTheShift.filter(p => (p.id !== mainPublisher.id && (p.id === mainPublisher.familyHeadId) || (p.familyHeadId === mainPublisher.id)));
if (familyMembers.length > 0 && (shift.assignments.length + familyMembers.length + 1) <= event.numberOfPublishers) {
console.log("Assigning " + mainPublisher.firstName + " " + mainPublisher.lastName + " and " + familyMembers.length + " available family members to " + new Date(shift.startTime).getDate() + " " + shift.name);
const hasTransportInAvailability = shift.transportIn && mainPublisher.availabilities.some(avail => avail.isWithTransportIn);
const hasTransportOutAvailability = shift.transportOut && mainPublisher.availabilities.some(avail => avail.isWithTransportOut);
const newAssignment = await prisma.assignment.create({
data: {
shift: {
connect: {
id: shift.id,
},
},
publisher: {
connect: {
id: mainPublisher.id,
},
},
isConfirmed: false,
isBySystem: false,
isWithTransport: (hasTransportInAvailability || hasTransportOutAvailability),
},
});
shift.assignments.push(newAssignment);
publishersToday.push(mainPublisher.id);
updateRegistry(mainPublisher.id, day, weekNr);
for (const familyMember of familyMembers) {
const newFamilyAssignment = await prisma.assignment.create({
data: {
shift: {
connect: {
id: shift.id,
},
},
publisher: {
connect: {
id: familyMember.id,
},
},
isConfirmed: false,
isBySystem: false,
isWithTransport: shift.requiresTransport,
},
});
shift.assignments.push(newFamilyAssignment);
publishersToday.push(familyMember.id);
updateRegistry(familyMember.id, day, weekNr);
}
break;
} else if (familyMembers.length == 0) {
console.log("Assigning " + mainPublisher.firstName + " " + mainPublisher.lastName + " to " + new Date(shift.startTime).getDate() + " " + shift.name);
const hasTransportInAvailability = shift.transportIn && mainPublisher.availabilities.some(avail => avail.isWithTransportIn);
const hasTransportOutAvailability = shift.transportOut && mainPublisher.availabilities.some(avail => avail.isWithTransportOut);
const newAssignment = await prisma.assignment.create({
data: {
shift: {
connect: {
id: shift.id,
},
},
publisher: {
connect: {
id: mainPublisher.id,
},
},
isConfirmed: false,
isBySystem: false,
isWithTransport: (hasTransportInAvailability || hasTransportOutAvailability),
},
});
shift.assignments.push(newAssignment);
publishersToday.push(mainPublisher.id);
updateRegistry(mainPublisher.id, day, weekNr);
break;
}
}
}
}
async function FilterInappropriatePublishers(availablePublishers, pubsToExclude, shift, maxShifts = 0) {
//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
|| p.currentMonthAssignments >= maxShifts; // overwrite the desiredShiftsPerMonth to max 10 shifts per month
return isNotAssigned && isNotAssignedToday && (!isAssignedEnough || maxShifts == 0);
});
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: Date) {
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;
p.finalScore = p.score;
if (p.finalScore > 0) {
p.penalties.forEach(penalty => {
p.finalScore *= penalty.penalty;
});
}
});
// Sort publishers based on score
let ranked = publishers.sort((a, b) => b.finalScore - a.finalScore);
// Log the scores and penalties of the top publisher
if (ranked.length > 0) {
const avgScore = (ranked.reduce((acc: number, val: { score: number }) => acc + val.score, 0) / ranked.length).toFixed(2);
const topPublisher = ranked[0];
const lastPublisher = ranked[ranked.length - 1];
console.log(`Top Publisher: ${topPublisher.firstName} ${topPublisher.lastName} (${ranked.length} available)`,
` Score: ${topPublisher.score.toFixed(2)}`, "last score: ", lastPublisher.score.toFixed(2), "Avg: ", avgScore,
` Penalties: `, topPublisher.penalties);
}
return ranked;
}
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;
}
// ### 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 };
}
}
// *********************************************************************************************************************
//region helpers
// *********************************************************************************************************************
// // *********************************************************************************************************************
// // NEW implementation
// // *********************************************************************************************************************
// async function GenerateScheduleNew(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay, algType = 0) {
// const prisma = common.getPrismaClient();
// const monthInfo = common.getMonthDatesInfo(new Date(date));
// const lastMonthInfo = common.getMonthDatesInfo(new Date(monthInfo.date.getFullYear(), monthInfo.date.getMonth() - 1, 1));
// // 0. Generate empty shifts for the whole period
// let shiftPool = await generateEmptyShifts(monthInfo, forDay);
// // 1. Copy shifts from last month if requested
// if (copyFromPreviousMonth) {
// shiftPool = await copyShiftsFromLastMonth(shiftPool, lastMonthInfo);
// }
// // 2. Get all publishers
// const allPublishers = await getAllPublishers(monthInfo);
// // 3. Fill shifts by priority
// await fillShiftsByPriority(shiftPool, allPublishers);
// return { message: "Schedule generated successfully", shifts: shiftPool };
// }
// async function generateEmptyShifts(monthInfo, forDay) {
// const prisma = common.getPrismaClient();
// const startDate = forDay ? monthInfo.date : monthInfo.firstMonday;
// const endDate = forDay ? addDays(monthInfo.date, 1) : monthInfo.lastSunday;
// const events = await prisma.cartEvent.findMany({
// where: { isActive: true }
// });
// let shifts = [];
// for (let day = startDate; day < endDate; day = addDays(day, 1)) {
// const dayOfWeek = common.DaysOfWeekArray[day.getDayEuropean()];
// const event = events.find(e => e.dayofweek === dayOfWeek);
// if (event) {
// let shiftStart = set(day, { hours: event.startTime.getHours(), minutes: event.startTime.getMinutes() });
// const shiftEnd = set(day, { hours: event.endTime.getHours(), minutes: event.endTime.getMinutes() });
// while (shiftStart < shiftEnd) {
// const nextShiftEnd = addMinutes(shiftStart, event.shiftDuration);
// const newShift = await prisma.shift.create({
// data: {
// startTime: shiftStart,
// endTime: nextShiftEnd,
// requiredPublishers: event.numberOfPublishers,
// requiresTransport: shiftStart.getTime() === event.startTime.getTime() || nextShiftEnd.getTime() === event.endTime.getTime(),
// assignments: [],
// weight: 0
// }
// });
// shifts.push(newShift);
// shiftStart = nextShiftEnd;
// }
// }
// }
// return shifts;
// }
// async function copyShiftsFromLastMonth(shiftPool, lastMonthInfo) {
// const prisma = common.getPrismaClient();
// const lastMonthShifts = await prisma.shift.findMany({
// where: {
// startTime: {
// gte: lastMonthInfo.firstMonday,
// lt: lastMonthInfo.lastSunday
// }
// },
// include: {
// assignments: {
// include: {
// publisher: {
// include: {
// availabilities: true
// }
// }
// }
// }
// }
// });
// for (const shift of shiftPool) {
// const matchingLastMonthShift = lastMonthShifts.find(s =>
// s.startTime.getHours() === shift.startTime.getHours() &&
// s.startTime.getMinutes() === shift.startTime.getMinutes() &&
// s.startTime.getDay() === shift.startTime.getDay()
// );
// if (matchingLastMonthShift) {
// for (const assignment of matchingLastMonthShift.assignments) {
// const isStillAvailable = await checkPublisherAvailability(assignment.publisher, shift);
// if (isStillAvailable) {
// shift.assignments.push({
// publisherId: assignment.publisher.id,
// isWithTransport: assignment.isWithTransport,
// isConfirmed: false,
// isBySystem: true
// });
// }
// }
// }
// }
// return shiftPool;
// }
// async function getAllPublishers(monthInfo) {
// const prisma = common.getPrismaClient();
// const publishers = await prisma.publisher.findMany({
// where: { isActive: true },
// include: {
// availabilities: {
// where: {
// startTime: {
// gte: monthInfo.firstMonday,
// lt: monthInfo.lastSunday
// }
// }
// }
// }
// });
// return publishers.map(p => ({
// ...p,
// currentMonthAssignments: 0,
// lastAssignmentDate: null
// }));
// }
// async function fillShiftsByPriority(shiftPool, allPublishers) {
// let remainingShifts = [...shiftPool];
// while (remainingShifts.length > 0) {
// updateShiftWeights(remainingShifts);
// remainingShifts.sort((a, b) => b.weight - a.weight);
// const currentShift = remainingShifts[0];
// const requireTransport = currentShift.requiresTransport && currentShift.assignments.filter(a => a.isWithTransport).length < 2;
// const availablePublishers = await getAvailablePublishersForShift(currentShift, allPublishers, requireTransport);
// const rankedPublishers = rankPublishersForShift(availablePublishers, currentShift, requireTransport);
// if (rankedPublishers.length > 0) {
// const selectedPublisher = rankedPublishers[0];
// await assignPublisherToShift(selectedPublisher, currentShift, requireTransport);
// updatePublisherStats(selectedPublisher, currentShift);
// }
// if (currentShift.assignments.length >= currentShift.requiredPublishers) {
// remainingShifts.shift();
// }
// }
// }
// function updateShiftWeights(shifts) {
// for (const shift of shifts) {
// const remainingSlots = shift.requiredPublishers - shift.assignments.length;
// const transportWeight = shift.requiresTransport && shift.assignments.filter(a => a.isWithTransport).length < 2 ? 10 : 0;
// const timeWeight = shift.startTime.getHours() < 12 ? 5 : 0; // Prioritize morning shifts
// shift.weight = remainingSlots * 10 + transportWeight + timeWeight;
// }
// }
// async function getAvailablePublishersForShift(shift, allPublishers, requireTransport) {
// return allPublishers.filter(publisher => {
// const isAvailable = publisher.availabilities.some(avail =>
// avail.startTime <= shift.startTime && avail.endTime >= shift.endTime
// );
// const isNotAssigned = !shift.assignments.some(a => a.publisherId === publisher.id);
// const hasTransport = requireTransport ? publisher.canProvideTransport : true;
// return isAvailable && isNotAssigned && hasTransport;
// });
// }
// function rankPublishersForShift(publishers, shift, requireTransport) {
// const weights = {
// gender: 2,
// desiredCompletion: 3,
// currentAssignments: 2,
// lastAssignment: 2,
// fairness: 3,
// transport: requireTransport ? 5 : 0
// };
// return publishers.map(p => ({
// ...p,
// score: calculatePublisherScore(p, shift, weights)
// })).sort((a, b) => b.score - a.score);
// }
// function calculatePublisherScore(publisher, shift, weights) {
// const genderScore = publisher.gender === 'male' ? weights.gender : 0;
// const desiredCompletionScore = (publisher.desiredShiftsPerMonth - publisher.currentMonthAssignments) * weights.desiredCompletion;
// const currentAssignmentsScore = -publisher.currentMonthAssignments * weights.currentAssignments;
// const lastAssignmentScore = calculateLastAssignmentScore(publisher, shift) * weights.lastAssignment;
// const fairnessScore = calculateFairnessScore(publisher) * weights.fairness;
// const transportScore = publisher.canProvideTransport ? weights.transport : 0;
// return genderScore + desiredCompletionScore + currentAssignmentsScore + lastAssignmentScore + fairnessScore + transportScore;
// }
// function calculateLastAssignmentScore(publisher, shift) {
// if (!publisher.lastAssignmentDate) return 1;
// const daysSinceLastAssignment = differenceInDays(shift.startTime, publisher.lastAssignmentDate);
// return Math.min(daysSinceLastAssignment / 7, 1); // Normalize to 0-1 range, max out at 7 days
// }
// function calculateFairnessScore(publisher) {
// const utilizationRate = publisher.currentMonthAssignments / publisher.desiredShiftsPerMonth;
// return 1 - utilizationRate; // Higher score for less utilized publishers
// }
// async function assignPublisherToShift(publisher, shift, withTransport) {
// const prisma = common.getPrismaClient();
// const assignment = await prisma.assignment.create({
// data: {
// shift: { connect: { id: shift.id } },
// publisher: { connect: { id: publisher.id } },
// isWithTransport: withTransport,
// isConfirmed: false,
// isBySystem: true,
// },
// });
// shift.assignments.push(assignment);
// }
// function updatePublisherStats(publisher, shift) {
// publisher.currentMonthAssignments++;
// publisher.lastAssignmentDate = shift.startTime;
// }
// async function checkPublisherAvailability(publisher, shift) {
// return publisher.availabilities.some(avail =>
// avail.startTime <= shift.startTime && avail.endTime >= shift.endTime
// );
// }