brand new schedule auto generation

This commit is contained in:
Dobromir Popov
2024-05-23 02:53:15 +03:00
parent cf4546e754
commit cb129a3709
3 changed files with 1284 additions and 442 deletions

File diff suppressed because it is too large Load Diff

View File

@ -363,7 +363,11 @@ exports.getDateFormated = function (date) {
return `${dayOfWeekName} ${day} ${monthName} ${year} г.`;
}
exports.getDateFormatedShort = function (date) {
exports.getDateFormattedShort = function (date) {
if (!date) {
return "";
}
const day = date.getDate();
const monthName = exports.getMonthName(date.getMonth());
return `${day} ${monthName}`;
@ -592,8 +596,13 @@ exports.setTime = (baseDateTime, timeDateTime) => {
});
};
exports.timeToInteger = (hours, minutes) => {
return hours * 100 + minutes;
}
// Format date to a specified format, defaulting to 'HH:mm'
exports.getTimeFormatted = (input, format = 'HH:mm') => {
if (!input) return "";
const dateTime = parseDate(input);
return dateTime.toFormat(format);
};
@ -695,6 +704,16 @@ exports.parseBool = function (value) {
return truthyValues.includes(String(value).toLowerCase());
}
exports.getStartOfDay = function (date) {
const result = new Date(date); // create a copy of the input date
result.setHours(0, 0, 0, 0); // set time to midnight
return result;
}
exports.getEndOfDay = function (date) {
const result = new Date(date);
result.setHours(23, 59, 59, 999); // set time to the last millisecond of the day
return result;
}
exports.getStartOfWeek = function (date) {
const result = new Date(date); // create a copy of the input date

View File

@ -1,5 +1,9 @@
const common = require('./common');
// const { Prisma, PrismaClient, Publisher, Shift, DayOfWeek } = require("@prisma/client");
// or
const DayOfWeek = require("@prisma/client").DayOfWeek;
async function findPublisher(names, email, select, getAll = false) {
// Normalize and split the name if provided
@ -78,53 +82,28 @@ async function findPublisher(names, email, select, getAll = false) {
}
}
async function findPublisherAvailability(publisherId, date) {
const prisma = common.getPrismaClient();
date = new Date(date); // Convert to date object if not already
const hours = date.getHours();
const minutes = date.getMinutes();
const potentialAvailabilities = await prisma.availability.findMany({
where: {
publisherId: publisherId,
AND: [ // Ensure both conditions must be met
{
startTime: {
lte: new Date(date), // startTime is less than or equal to the date
},
},
{
endTime: {
gte: new Date(date), // endTime is greater than or equal to the date
},
},
],
}
});
//# new - to verify
// should be equivalent to the following prisma filer
// whereClause["availabilities"] = {
// some: {
// OR: [
// // Check if dayOfMonth is set and filterDate is between start and end dates (Assignments on specific days AND time)
// {
// dayOfMonth: filterDate.getDate(),
// startTime: { lte: filterDate },
// endTime: { gte: filterDate }
// },
// // Check if dayOfMonth is null and match by day of week using the enum (Assigments every week)
// {
// dayOfMonth: null,
// dayofweek: dayOfWeekEnum,
// startTime: { gte: filterDate },
// }
// ]
// }
// };
if (potentialAvailabilities.length === 0) {
return null; // No availability found
}
// Filter the results based on time and other criteria when not exact date match
const availability = potentialAvailabilities.find(avail => {
const availStartHours = avail.startTime.getHours();
const availStartMinutes = avail.startTime.getMinutes();
const availEndHours = avail.endTime.getHours();
const availEndMinutes = avail.endTime.getMinutes();
const isAfterStartTime = hours > availStartHours || (hours === availStartHours && minutes >= availStartMinutes);
const isBeforeEndTime = hours < availEndHours || (hours === availEndHours && minutes <= availEndMinutes);
// check day of week if not null
const isCorrectDayOfWeek = avail.repeatWeekly ? avail.startTime.getDay() === date.getDay() : true;
const isExactDateMatch = avail.dayOfMonth ? avail.startTime.toDateString() === date.toDateString() : true;
const isBeforeEndDate = avail.repeatWeekly ? true : avail.endTime > date;
//const isCorrectWeekOfMonth = avail.repeatWeekly ? true : avail.weekOfMonth === weekOfMonth;
return isAfterStartTime && isBeforeEndTime && isCorrectDayOfWeek && isExactDateMatch && isBeforeEndDate;
});
return availability;
}
async function getAvailabilities(userId) {
@ -227,7 +206,21 @@ async function getAvailabilities(userId) {
}
/**
* Filters publishers based on various criteria including exact times, monthly duration,
* and whether or not to include statistics about publishers' availabilities and assignments.
* This function heavily relies on the `prisma` client to query and manipulate data related to publishers.
*
* @param {Array|string} selectFields - Fields to select from the publishers data. Can be an array of field names or a comma-separated string of field names.
* @param {string|Date} filterDate - The reference date for filtering. Can be a date string or a Date object. Used to determine relevant time frames like current month, previous month, etc.
* @param {boolean} [isExactTime=false] - If true, filters publishers who are available at the exact time of `filterDate` plus/minus a specific duration (e.g., 90 minutes).
* @param {boolean} [isForTheMonth=false] - If true, adjusts the filtering to encompass the entire month based on `filterDate`.
* @param {boolean} [noEndDateFilter=false] - If true, removes any filtering based on the end date of publishers' availabilities.
* @param {boolean} [isWithStats=true] - If true, includes statistical data about publishers' availabilities and assignments in the output.
* @param {boolean} [includeOldAvailabilities=false] - If true, includes publishers' previous availabilities in the calculations and output.
*
* @returns {Promise<Array>} Returns a promise that resolves to an array of publishers with filtered data according to the specified criteria.
*/
async function filterPublishersNew(selectFields, filterDate, isExactTime = false, isForTheMonth = false, noEndDateFilter = false, isWithStats = true, includeOldAvailabilities = false) {
const prisma = common.getPrismaClient();
@ -347,7 +340,7 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
}
console.log(`getting publishers for date: ${filterDate}, isExactTime: ${isExactTime}, isForTheMonth: ${isForTheMonth}`);
console.log`whereClause: ${JSON.stringify(whereClause)}`
//console.log`whereClause: ${JSON.stringify(whereClause)}`
//include availabilities if flag is true
let publishers = await prisma.publisher.findMany({
where: whereClause,
@ -357,8 +350,9 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
}
});
console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`);
///console.log(`publishers: ${publishers.length}, WhereClause: ${JSON.stringify(whereClause)}`);
// include repeating weekly availabilities. generate occurrences for the month
// convert matching weekly availabilities to availabilities for the day to make further processing easier on the client.
// we trust that the filtering was OK, so we use the dateFilter as date.
publishers.forEach(pub => {
@ -448,16 +442,16 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
}
});
// ToDo: test case/unit test
// ToDo: check and validate the filtering and calculations
if (isExactTime) {
//HERE WE FILTER by time for repeating availabilities. We can't do that if we don't have
// whereClause["availabilities"].some.OR[1].startTime = { gte: filterTimeFrom };
// whereClause["availabilities"].some.OR[1].endTime = { gte: filterTimeTo }
publishers.forEach(pub => {
pub.availabilities.filter(a => a.startTime > filterTimeFrom && a.endTime < filterTimeTo)
pub.availabilities = pub.availabilities.filter(a => a.startTime <= filterTimeFrom && a.endTime >= filterTimeTo);
});
publishers.filter(pub => pub.availabilities.length > 0);
publishers = publishers.filter(pub => pub.availabilities.length > 0);
}
// if (isExactTime) {
@ -472,10 +466,10 @@ async function filterPublishersNew(selectFields, filterDate, isExactTime = false
}
//ToDo: refactor this function
async function getAllPublishersWithStatistics(filterDate, noEndDateFilter = false) {
async function getAllPublishersWithStatisticsMonth(filterDateDuringMonth, noEndDateFilter = false, includeOldAvailabilities = true) {
const prisma = common.getPrismaClient();
const monthInfo = common.getMonthDatesInfo(new Date(filterDate));
const monthInfo = common.getMonthDatesInfo(new Date(filterDateDuringMonth));
const dateStr = new Date(monthInfo.firstMonday).toISOString().split('T')[0];
let publishers = await filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', dateStr, false, true, noEndDateFilter, true, true);
@ -787,6 +781,393 @@ async function getCoverMePublisherEmails(shiftId) {
return { shift, availablePublishers: availablePublishers, subscribedPublishers };
}
// ### COPIED TO shift api (++) ###
/** JSDoc
* Generates a schedule.
*
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.
*
* @param {Axios} axios Axios instance for making requests.
* @param {string} date The date for the schedule.
* @param {boolean} [copyFromPreviousMonth=false] Whether to copy from the previous month.
* @param {boolean} [autoFill=false] Whether to autofill data.
* @param {boolean} forDay Specific day flag.
*/
async function GenerateSchedule(axios, date, copyFromPreviousMonth = false, autoFill = false, forDay) {
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 getAllPublishersWithStatisticsMonth('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', date, false, true, false, true, true);
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 filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, true);
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.publisher;
console.log("found publisher from last month: " + publisher.firstName + " " + publisher.lastName);
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,
isWithTransportIn: availability.isWithTransportIn,
isWithTransportOut: availability.isWithTransportOut
});
publishersThisWeek.push(publisher.id);
}
}
}
let publishersNeeded = event.numberOfPublishers - shiftAssignments.length;
//ToDo: check if getAvailablePublishersForShift is working correctly
let availablePublishers = await getAvailablePublishersForShift(shiftStart, shiftEnd, publishers, publishersThisWeek);
console.log("shift " + __shiftName + " needs " + publishersNeeded + " publishers, available: " + availablePublishers.length + " for the day: " + availablePubsForTheDay.length);
// Prioritize publishers with minimal availability
// SKIP ADDING PUBLISHERS FOR NOW
// availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount);
// for (let i = 0; i < publishersNeeded; i++) {
// if (availablePublishers[i]) {
// shiftAssignments.push({ publisherId: availablePublishers[i].id });
// publishersThisWeek.push(availablePublishers[i].id);
// }
// }
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 }
},
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,
},
},
},
});
console.log(" second pass " + monthInfo.monthName + " " + monthInfo.year);
// 2. First pass - prioritize shifts with transport where it is needed
day = 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 filterPublishersNew('id,firstName,lastName,email,isActive,desiredShiftsPerMonth,lastLogin,type', day, false, false, false, true, true);
let shifts = allShifts.filter(s => common.getISODateOnly(s.startTime) === common.getISODateOnly(day));
let transportShifts = shifts.filter(s => s.requiresTransport);
transportShifts.forEach(shift => {
let availablePublishers = availablePubsForTheDay.filter(p => !shift.assignments.some(a => a.publisher.id === p.id));
availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount);
let publishersNeeded = event.numberOfPublishers - shift.assignments.length;
if (publishersNeeded > 0) {//get the beset match
if (availablePublishers[0]) {
shift.assignments.push({ publisherId: availablePublishers[i].id });
}
}
});
// 3. Second pass - fill the rest of the shifts
let shiftsToFill = shifts.filter(s => !s.requiresTransport);
shiftsToFill.forEach(shift => {
let availablePublishers = availablePubsForTheDay.filter(p => !shift.assignments.some(a => a.publisher.id === p.id));
availablePublishers = availablePublishers.sort((a, b) => a.currentMonthAvailabilityHoursCount - b.currentMonthAvailabilityHoursCount);
let publishersNeeded = event.numberOfPublishers - shift.assignments.length;
if (publishersNeeded > 0) {//get the beset match
if (availablePublishers[0]) {
shift.assignments.push({ publisherId: availablePublishers[i].id });
}
}
});
}
day.setDate(day.getDate() + 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 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 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 matchesAvailability(avail, filterDate) {
// // Setting the start and end time of the filterDate
// filterDate.setHours(0, 0, 0, 0);
@ -824,11 +1205,14 @@ async function runSqlFile(filePath) {
module.exports = {
findPublisher,
findPublisherAvailability,
FindPublisherAvailability,
runSqlFile,
getAvailabilities,
filterPublishersNew,
getCoverMePublisherEmails,
getAllPublishersWithStatistics,
getCalendarEvents
getAllPublishersWithStatisticsMonth,
getCalendarEvents,
GenerateSchedule,
DeleteShiftsForMonth,
DeleteShiftsForDay,
};