diff --git a/client/package.json b/client/package.json index 71d7bfa89..1153baef0 100644 --- a/client/package.json +++ b/client/package.json @@ -117,9 +117,9 @@ "devDependencies": { "@sentry/webpack-plugin": "^1.18.3", "@testing-library/cypress": "^8.0.2", - "react-error-overlay": "6.0.9", "cypress": "^9.1.1", "eslint-plugin-cypress": "^2.12.1", + "react-error-overlay": "6.0.9", "redux-logger": "^3.0.6", "source-map-explorer": "^2.5.2" } diff --git a/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx b/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx index 1f38fa348..942f2908f 100644 --- a/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx +++ b/client/src/components/schedule-calendar-wrapper/scheduler-calendar-wrapper.component.jsx @@ -10,13 +10,17 @@ import Event from "../job-at-change/schedule-event.container"; import HeaderComponent from "./schedule-calendar-header.component"; import "./schedule-calendar.styles.scss"; import JobDetailCards from "../job-detail-cards/job-detail-cards.component"; +import { selectProblemJobs } from "../../redux/application/application.selectors"; +import { Alert } from "antd"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, + problemJobs: selectProblemJobs, }); const localizer = momentLocalizer(moment); export function ScheduleCalendarWrapperComponent({ bodyshop, + problemJobs, data, refetch, defaultView, @@ -48,6 +52,15 @@ export function ScheduleCalendarWrapperComponent({ return ( <> + {problemJobs && + problemJobs.map((problem) => ( + + ))} + + + ); + else return null; +} diff --git a/client/src/graphql/appointments.queries.js b/client/src/graphql/appointments.queries.js index c0495c2b5..529cf68a7 100644 --- a/client/src/graphql/appointments.queries.js +++ b/client/src/graphql/appointments.queries.js @@ -234,11 +234,17 @@ export const QUERY_SCHEDULE_LOAD_DATA = gql` } } compJobs: jobs( - where: { scheduled_completion: { _gte: $start, _lte: $end } } + where: { + _or: [ + { scheduled_completion: { _gte: $start, _lte: $end } } + { actual_completion: { _gte: $start, _lte: $end } } + ] + } ) { id ro_number scheduled_completion + actual_completion labhrs: joblines_aggregate( where: { mod_lbr_ty: { _neq: "LAR" }, removed: { _eq: false } } ) { diff --git a/client/src/redux/application/application.actions.js b/client/src/redux/application/application.actions.js index b1e42b986..7e7a66f84 100644 --- a/client/src/redux/application/application.actions.js +++ b/client/src/redux/application/application.actions.js @@ -58,3 +58,7 @@ export const insertAuditTrail = ({ jobid, billid, operation }) => ({ type: ApplicationActionTypes.INSERT_AUDIT_TRAIL, payload: { jobid, billid, operation }, }); +export const setProblemJobs = (problemJobs) => ({ + type: ApplicationActionTypes.SET_PROBLEM_JOBS, + payload: problemJobs, +}); diff --git a/client/src/redux/application/application.reducer.js b/client/src/redux/application/application.reducer.js index 60685e2cc..95579574a 100644 --- a/client/src/redux/application/application.reducer.js +++ b/client/src/redux/application/application.reducer.js @@ -6,6 +6,7 @@ const INITIAL_STATE = { breadcrumbs: [], recentItems: [], selectedHeader: "home", + problemJobs: [], scheduleLoad: { load: {}, calculating: false, @@ -40,6 +41,7 @@ const applicationReducer = (state = INITIAL_STATE, action) => { case ApplicationActionTypes.CALCULATE_SCHEDULE_LOAD: return { ...state, + problemJobs: [], scheduleLoad: { ...state.scheduleLoad, calculating: true, error: null }, }; case ApplicationActionTypes.CALCULATE_SCHEDULE_LOAD_SUCCESS: @@ -76,7 +78,9 @@ const applicationReducer = (state = INITIAL_STATE, action) => { case ApplicationActionTypes.SET_PARTNER_VERSION: return { ...state, partnerVersion: action.payload }; - + case ApplicationActionTypes.SET_PROBLEM_JOBS: { + return { ...state, problemJobs: action.payload }; + } default: return state; } diff --git a/client/src/redux/application/application.sagas.js b/client/src/redux/application/application.sagas.js index 3b6057052..fceaab4e1 100644 --- a/client/src/redux/application/application.sagas.js +++ b/client/src/redux/application/application.sagas.js @@ -7,6 +7,7 @@ import { CalculateLoad, CheckJobBucket } from "../../utils/SSSUtils"; import { scheduleLoadFailure, scheduleLoadSuccess, + setProblemJobs, } from "./application.actions"; import ApplicationActionTypes from "./application.types"; @@ -53,6 +54,8 @@ export function* calculateScheduleLoad({ payload: end }) { }); arrJobs.forEach((item) => { + if (!item.scheduled_in) + console.log("JOB HAS NO SCHEDULED IN DATE.", item); const itemDate = moment(item.scheduled_in).format("yyyy-MM-DD"); if (!!load[itemDate]) { load[itemDate].hoursIn = @@ -71,7 +74,26 @@ export function* calculateScheduleLoad({ payload: end }) { } }); + let problemJobs = []; compJobs.forEach((item) => { + if (!item.scheduled_completion) + console.log("JOB HAS NO SCHEDULED COMPLETION DATE.", item); + + const inProdJobs = prodJobs.find((p) => p.id === item.id); + const inArrJobs = arrJobs.find((p) => p.id === item.id); + + if ( + !(inProdJobs || inArrJobs) && + !moment(item.scheduled_completion).isSame(moment(), "day") + ) { + // NOT FOUND! + problemJobs.push({ + ...item, + code: "Job is scheduled for completion, but it is not marked in production nor is it an arriving job in this period. Check the scheduled in and completion dates", + }); + return; + } + const itemDate = moment(item.scheduled_completion).format("yyyy-MM-DD"); if (!!load[itemDate]) { load[itemDate].hoursOut = @@ -116,7 +138,7 @@ export function* calculateScheduleLoad({ payload: end }) { ); } } - + yield put(setProblemJobs(problemJobs)); yield put(scheduleLoadSuccess(load)); } catch (error) { yield put(scheduleLoadFailure(error)); diff --git a/client/src/redux/application/application.selectors.js b/client/src/redux/application/application.selectors.js index 5c48a17d1..cb5e2c679 100644 --- a/client/src/redux/application/application.selectors.js +++ b/client/src/redux/application/application.selectors.js @@ -44,3 +44,7 @@ export const selectOnline = createSelector( [selectApplication], (application) => application.online ); +export const selectProblemJobs = createSelector( + [selectApplication], + (application) => application.problemJobs +); diff --git a/client/src/redux/application/application.types.js b/client/src/redux/application/application.types.js index 85b89ac0d..1e047bb82 100644 --- a/client/src/redux/application/application.types.js +++ b/client/src/redux/application/application.types.js @@ -11,5 +11,6 @@ const ApplicationActionTypes = { SET_PARTNER_VERSION: "SET_PARTNER_VERSION", SET_ONLINE_STATUS: "SET_ONLINE_STATUS", INSERT_AUDIT_TRAIL: "INSERT_AUDIT_TRAIL", + SET_PROBLEM_JOBS: "SET_PROBLEM_JOBS", }; export default ApplicationActionTypes; diff --git a/client/src/utils/SSSUtils.js b/client/src/utils/SSSUtils.js index c0e457b03..0d4bd5aa9 100644 --- a/client/src/utils/SSSUtils.js +++ b/client/src/utils/SSSUtils.js @@ -30,6 +30,9 @@ export const CalculateLoad = (currentLoad, buckets, jobsIn, jobsOut) => { const bucketId = CheckJobBucket(buckets, job); if (bucketId) { newLoad[bucketId].count = newLoad[bucketId].count - 1; + if (newLoad[bucketId].count < 0) { + console.log("***ERROR: NEGATIVE LOAD", bucketId, job); + } } else { console.log( "[Util Out Job]Uh oh, this job doesn't fit in a bucket!", diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 86459e3d1..a7cf85be8 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -454,15 +454,14 @@ exports.QUERY_PAYMENTS_FOR_EXPORT = ` } `; -exports.QUERY_UPCOMING_APPOINTMENTS = ` -query QUERY_UPCOMING_APPOINTMENTS($now: timestamptz!, $jobId: uuid!) { +exports.QUERY_UPCOMING_APPOINTMENTS = `query QUERY_UPCOMING_APPOINTMENTS($now: timestamptz!, $jobId: uuid!) { jobs_by_pk(id: $jobId) { bodyshop { ssbuckets target_touchtime workingdays } - jobhrs: joblines_aggregate(where: { removed: { _eq: false } }) { + jobhrs: joblines_aggregate(where: {removed: {_eq: false}}) { aggregate { sum { mod_lb_hrs @@ -470,40 +469,60 @@ query QUERY_UPCOMING_APPOINTMENTS($now: timestamptz!, $jobId: uuid!) { } } } - appointments(where: {_and: {canceled: {_eq: false}, start: {_gte: $now}}}) { + blockedDays: appointments(where: {_and: {canceled: {_eq: false}, block: {_eq: true}, start: {_gte: $now}}}) { start - isintake - id block - job { - joblines_aggregate(where: { removed: { _eq: false } }) { - aggregate { - sum { - mod_lb_hrs - } + } + arrJobs: jobs(where: {scheduled_in: {_gte: $now}}) { + id + scheduled_in + ro_number + labhrs: joblines_aggregate(where: {mod_lbr_ty: {_neq: "LAR"}, removed: {_eq: false}}) { + aggregate { + sum { + mod_lb_hrs + } + } + } + larhrs: joblines_aggregate(where: {mod_lbr_ty: {_eq: "LAR"}, removed: {_eq: false}}) { + aggregate { + sum { + mod_lb_hrs } } } } - jobs(where: {inproduction: {_eq: true}}) { + compJobs: jobs(where: {_or: [{scheduled_completion: {_gte: $now}}, {actual_completion: {_gte: $now}}]}) { + id + ro_number + scheduled_completion + actual_completion + labhrs: joblines_aggregate(where: {mod_lbr_ty: {_neq: "LAR"}, removed: {_eq: false}}) { + aggregate { + sum { + mod_lb_hrs + } + } + } + larhrs: joblines_aggregate(where: {mod_lbr_ty: {_eq: "LAR"}, removed: {_eq: false}}) { + aggregate { + sum { + mod_lb_hrs + } + } + } + } + prodJobs: jobs(where: {inproduction: {_eq: true}}) { id scheduled_completion - labhrs: joblines_aggregate( - where: { - _and: [{ mod_lbr_ty: { _neq: "LAR" } }, { removed: { _eq: false } }] - } - ) { + labhrs: joblines_aggregate(where: {_and: [{mod_lbr_ty: {_neq: "LAR"}}, {removed: {_eq: false}}]}) { aggregate { sum { mod_lb_hrs } } } - larhrs: joblines_aggregate( - where: { - _and: [{ mod_lbr_ty: { _eq: "LAR" } }, { removed: { _eq: false } }] - } - ) { + larhrs: joblines_aggregate(where: {_and: [{mod_lbr_ty: {_eq: "LAR"}}, {removed: {_eq: false}}]}) { aggregate { sum { mod_lb_hrs @@ -512,6 +531,9 @@ query QUERY_UPCOMING_APPOINTMENTS($now: timestamptz!, $jobId: uuid!) { } } } + + + `; exports.QUERY_EMPLOYEE_PIN = `query QUERY_EMPLOYEE_PIN($shopId: uuid!, $employeeId: String!) { diff --git a/server/scheduling/scheduling-job.js b/server/scheduling/scheduling-job.js index 6cb4c1809..8c06b5345 100644 --- a/server/scheduling/scheduling-job.js +++ b/server/scheduling/scheduling-job.js @@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries"); const Dinero = require("dinero.js"); const moment = require("moment"); const logger = require("../utils/logger"); +const _ = require("lodash"); require("dotenv").config({ path: path.resolve( process.cwd(), @@ -30,7 +31,7 @@ exports.job = async (req, res) => { jobId: jobId, }); - const { appointments, jobs } = result; + const { jobs_by_pk, blockedDays, prodJobs, arrJobs, compJobs } = result; const { ssbuckets, workingdays } = result.jobs_by_pk.bodyshop; const jobHrs = result.jobs_by_pk.jobhrs.aggregate.sum.mod_lb_hrs; @@ -39,105 +40,164 @@ exports.job = async (req, res) => { bucket.gte <= jobHrs && (!!bucket.lt ? bucket.lt > jobHrs : true) )[0]; - const bucketMatrix = {}; - const yesterday = moment().subtract(1, "day"); - //Get latest date + add 5 days to allow for back end adding.. + const load = { + productionTotal: {}, + }; + //Set the current load. + ssbuckets.forEach((bucket) => { + load.productionTotal[bucket.id] = { count: 0, label: bucket.label }; + }); - const totalMatrixDays = moment - .max([ - ...appointments.map((a) => moment(a.start)), - ...jobs - .map((p) => moment(p.scheduled_completion)) - .filter((p) => p.isValid() && p.isAfter(yesterday)), - ]) - .add("5", "days") - .diff(moment(), "days"); + const filteredProdJobsList = prodJobs.filter( + (j) => JobBucket.id === CheckJobBucket(ssbuckets, j) + ); - //Initialize the bucket matrix - for (var i = 0; i < totalMatrixDays; i++) { - const theDate = moment().add(i, "days").format("yyyy-MM-DD"); - //Only need to create a matrix for jobs of the same bucket. - bucketMatrix[theDate] = { in: 0, out: 0 }; - - // ssbuckets.forEach((bucket) => { - // bucketMatrix[theDate] = { - // ...bucketMatrix[theDate], - // [bucket.id]: { in: 0, out: 0 }, - // }; - // }); - } - - //Populate the jobs scheduled to come in. - appointments.forEach((appointment) => { - if (!appointment.block) { - const jobHrs = - appointment.job.joblines_aggregate.aggregate.sum.mod_lb_hrs; - //Is the job in the same bucket? - const appointmentBucket = ssbuckets.filter( - (bucket) => - bucket.gte <= jobHrs && (!!bucket.lt ? bucket.lt > jobHrs : true) - )[0]; - if (appointmentBucket.id === JobBucket.id) { - //Theyre the same classification. Add it to the matrix. - const appDate = moment(appointment.start).format("yyyy-MM-DD"); - bucketMatrix[appDate] = { - ...bucketMatrix[appDate], - in: bucketMatrix[appDate].in + 1, - }; - } + filteredProdJobsList.forEach((item) => { + //Add all of the jobs currently in production to the buckets so that we have a starting point. + const bucketId = CheckJobBucket(ssbuckets, item); + if (bucketId) { + load.productionTotal[bucketId].count = + load.productionTotal[bucketId].count + 1; } else { - //remove the date from the possible list. - const appDate = moment(appointment.start).format("yyyy-MM-DD"); - bucketMatrix[appDate] = { - ...bucketMatrix[appDate], - blocked: true, + console.log("Uh oh, this job doesn't fit in a bucket!", item); + } + }); + + const filteredArrJobs = arrJobs.filter( + (j) => JobBucket.id === CheckJobBucket(ssbuckets, j) + ); + + filteredArrJobs.forEach((item) => { + const itemDate = moment(item.scheduled_in).format("yyyy-MM-DD"); + if (!!load[itemDate]) { + load[itemDate].hoursIn = + (load[itemDate].hoursIn || 0) + + item.labhrs.aggregate.sum.mod_lb_hrs + + item.larhrs.aggregate.sum.mod_lb_hrs; + load[itemDate].jobsIn.push(item); + } else { + load[itemDate] = { + jobsIn: [item], + jobsOut: [], + hoursIn: + item.labhrs.aggregate.sum.mod_lb_hrs + + item.larhrs.aggregate.sum.mod_lb_hrs, }; } }); - //Populate the jobs that are leaving today. - const todayIsoString = moment().format("yyyy-MM-DD"); - jobs.forEach((pjob) => { - const jobHrs = - pjob.larhrs.aggregate.sum.mod_lb_hrs + - pjob.labhrs.aggregate.sum.mod_lb_hrs; - //Is the job in the same bucket? - const pjobBucket = ssbuckets.filter( - (bucket) => - bucket.gte <= jobHrs && (!!bucket.lt ? bucket.lt > jobHrs : true) - )[0]; - if (pjobBucket.id === JobBucket.id) { - //Theyre the same classification. Add it to the matrix. - const compDate = moment(pjob.scheduled_completion); - //Is the schedule completion behind today? If so, use today as it. - let dateToUse; - dateToUse = compDate.isValid() - ? moment().diff(compDate, "days") < 0 - ? compDate.format("yyyy-MM-DD") - : todayIsoString - : todayIsoString; + //Get the completing jobs. + let problemJobs = []; + const filteredCompJobs = compJobs.filter( + (j) => JobBucket.id === CheckJobBucket(ssbuckets, j) + ); - bucketMatrix[dateToUse] = { - ...bucketMatrix[dateToUse], - out: (bucketMatrix[dateToUse].out || 0) + 1, - }; - } - }); - - //Propose the first 5 dates where we are below target. - - const possibleDates = []; - const bucketMatrixKeys = Object.keys(bucketMatrix); - bucketMatrixKeys.forEach((bmkey) => { - const isShopOpen = - workingdays[dayOfWeekMapper(moment(bmkey).day())] && - !bucketMatrix[bmkey].blocked; + filteredCompJobs.forEach((item) => { + const inProdJobs = filteredProdJobsList.find((p) => p.id === item.id); + const inArrJobs = filteredArrJobs.find((p) => p.id === item.id); if ( - JobBucket.target > bucketMatrix[bmkey].in - bucketMatrix[bmkey].out && + !(inProdJobs || inArrJobs) && + !moment(item.actual_completion || item.scheduled_completion).isSame( + moment(), + "day" + ) + ) { + // NOT FOUND! + console.log("PROBLEM JOB", item); + problemJobs.push({ + ...item, + code: "Job is scheduled for completion, but it is not marked in production nor is it an arriving job in this period. Check the scheduled in and completion dates", + }); + return; + } else { + const itemDate = moment( + item.actual_completion || item.scheduled_completion + ).format("yyyy-MM-DD"); + if (!!load[itemDate]) { + load[itemDate].hoursOut = + (load[itemDate].hoursOut || 0) + + item.labhrs.aggregate.sum.mod_lb_hrs + + item.larhrs.aggregate.sum.mod_lb_hrs; + load[itemDate].jobsOut.push(item); + } else { + load[itemDate] = { + jobsOut: [item], + hoursOut: + item.labhrs.aggregate.sum.mod_lb_hrs + + item.larhrs.aggregate.sum.mod_lb_hrs, + }; + } + } + }); + + //Propagate the expected load to each day. + const yesterday = moment().subtract(1, "day"); + const today = moment().startOf("day"); + + const end = moment.max([ + ...filteredArrJobs.map((a) => moment(a.scheduled_in)), + ...filteredCompJobs + .map((p) => moment(p.actual_completion || p.scheduled_completion)) + .filter((p) => p.isValid() && p.isAfter(yesterday)), + ]); + const range = Math.round(moment.duration(end.diff(today)).asDays()); + for (var day = 0; day < range; day++) { + const current = moment(today).add(day, "days").format("yyyy-MM-DD"); + const prev = moment(today) + .add(day - 1, "days") + .format("yyyy-MM-DD"); + if (!!!load[current]) { + load[current] = {}; + } + if (day === 0) { + //Starting on day 1. The load is current. + load[current].expectedLoad = CalculateLoad( + load.productionTotal, + ssbuckets, + load[current].jobsIn || [], + load[current].jobsOut || [] + ); + } else { + load[current].expectedLoad = CalculateLoad( + load[prev].expectedLoad, + ssbuckets, + load[current].jobsIn || [], + load[current].jobsOut || [] + ); + } + } + + //Add in all of the blocked days. + + blockedDays.forEach((b) => { + //Find it in the load, set it as blocked. + const startIsoFormat = moment(b.start).format("YYYY-MM-DD"); + if (load[startIsoFormat]) load[startIsoFormat].blocked = true; + else { + load[startIsoFormat] = { blocked: true }; + } + }); + // //Propose the first 5 dates where we are below target. + + const possibleDates = []; + delete load.productionTotal; + const loadKeys = Object.keys(load).sort((a, b) => + moment(a).isAfter(moment(b)) ? 1 : -1 + ); + + loadKeys.forEach((loadKey) => { + const isShopOpen = + (workingdays[dayOfWeekMapper(moment(loadKey).day())] || false) && + !load[loadKey].blocked; + + if ( + load[loadKey].expectedLoad && + load[loadKey].expectedLoad[JobBucket.id] && + JobBucket.target > load[loadKey].expectedLoad[JobBucket.id].count && isShopOpen ) - possibleDates.push(new Date(bmkey).toISOString().substr(0, 10)); + possibleDates.push(new Date(loadKey).toISOString().substr(0, 10)); }); if (possibleDates.length < 6) { @@ -147,7 +207,7 @@ exports.job = async (req, res) => { } } catch (error) { logger.log("smart-scheduling-error", "ERROR", req.user.email, jobId, { - error: JSON.stringify(error), + error, }); res.status(400).send(error); } @@ -171,3 +231,47 @@ const dayOfWeekMapper = (numberOfDay) => { return "saturday"; } }; + +const CheckJobBucket = (buckets, job) => { + const jobHours = + job.labhrs.aggregate.sum.mod_lb_hrs + job.larhrs.aggregate.sum.mod_lb_hrs; + + const matchingBucket = buckets.filter((b) => + b.gte <= jobHours && b.lt ? b.lt > jobHours : true + ); + + return matchingBucket[0] && matchingBucket[0].id; +}; + +const CalculateLoad = (currentLoad, buckets, jobsIn, jobsOut) => { + //Add the jobs coming + const newLoad = _.cloneDeep(currentLoad); + jobsIn.forEach((job) => { + const bucketId = CheckJobBucket(buckets, job); + if (bucketId) { + newLoad[bucketId].count = newLoad[bucketId].count + 1; + } else { + console.log( + "[Util Arr Job]Uh oh, this job doesn't fit in a bucket!", + job + ); + } + }); + + jobsOut.forEach((job) => { + const bucketId = CheckJobBucket(buckets, job); + if (bucketId) { + newLoad[bucketId].count = newLoad[bucketId].count - 1; + if (newLoad[bucketId].count < 0) { + console.log("***ERROR: NEGATIVE LOAD Bucket =>", bucketId, job); + } + } else { + console.log( + "[Util Out Job]Uh oh, this job doesn't fit in a bucket!", + job + ); + } + }); + + return newLoad; +};