add business model processing

This commit is contained in:
Marcel Arndt
2024-07-15 12:53:11 +02:00
parent 8be07e1f9b
commit 53e945253c
65 changed files with 10638 additions and 6659 deletions
@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import {
Anomaly,
Prisma,
SolutionTarget,
SolutionTargetStep,
Tour,
} from '@prisma/client';
import { Interval } from 'date-fns';
import { TraceTypes } from 'src/core/base/trace.types';
import { PrismaService } from 'src/core/database/prisma.service';
import { AnomalyDetectorService } from './anomaly-detectors/anomaly-detector.service';
import { AnomalyTypes } from './anomaly-detectors/anomaly-types.enum';
import { DriverCertificateDetector } from './anomaly-detectors/driver-certificate.detector';
import { DriverCodriverSameDetector } from './anomaly-detectors/driver-codriver-same.detector';
import { EmployeesLoggedInDetector } from './anomaly-detectors/employees-logged-in.detector';
import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
import { WorkingEmployeeNotInDistributionSystemDetector } from './anomaly-detectors/working-employee-not-in-distribution-system.detector';
@Injectable()
export class AnomaliesService {
private detectors = [
new EmployeesLoggedInDetector(this.anomalyDetectorService),
new DriverCertificateDetector(this.anomalyDetectorService),
new DriverCodriverSameDetector(this.anomalyDetectorService),
new OneTourAtATimeDetector(this.anomalyDetectorService),
new WorkingEmployeeNotInDistributionSystemDetector(
this.anomalyDetectorService,
),
];
constructor(
private readonly prisma: PrismaService,
private readonly anomalyDetectorService: AnomalyDetectorService,
) {}
detectAnomalies(interval: Interval): Promise<Anomaly[]> {
return Promise.all(
this.detectors.map((detector) => detector.detect(interval)),
).then(async (anomaliesPerDetector) => {
const anomalies = anomaliesPerDetector.flat();
const anomalyEntities: Anomaly[] = [];
for (const anomaly of anomalies) {
if (anomaly) {
anomalyEntities.push(
await this.prisma.anomaly.upsert({
where: {
id: anomaly.id,
},
create: {
...anomaly,
},
update: {},
}),
);
}
}
return anomalyEntities;
});
}
async validateAnomalies(): Promise<Anomaly[]> {
const anomalies = await this.prisma.anomaly.findMany({
where: {
resolvedAt: null,
},
});
return Promise.all(
anomalies.map((anomaly) =>
this.detectors
.find((detector) => detector.type === anomaly.type)
.validate(anomaly),
),
).then(async (anomalies) => {
const validatedAnomalies = anomalies.flat();
const updatedAnomalies: Anomaly[] = [];
for (const anomaly of validatedAnomalies) {
updatedAnomalies.push(
await this.prisma.anomaly.update({
where: {
id: anomaly.id,
},
data: {
resolvedAt: anomaly.resolvedAt,
manuallyResolved: false,
},
}),
);
}
return updatedAnomalies;
});
}
countActiveAnomalies(types: AnomalyTypes[] = []): Promise<number> {
return this.prisma.anomaly.count({
where: this.getWhereActiveAnomalyQuery(types),
});
}
findActiveAnomalies(
skip?: number,
take?: number,
types: AnomalyTypes[] = [],
): Promise<Anomaly[]> {
return this.prisma.anomaly.findMany({
...{
...(skip ? { skip, take: 10 } : {}),
...(take ? { take } : {}),
...(take && !skip ? { skip: 0 } : {}),
},
where: this.getWhereActiveAnomalyQuery(types),
orderBy: {
groupKey: 'asc',
},
include: {
solution: true,
},
});
}
async findActiveAnomaliesGroupedByGroupKey(
skip = 0,
take = 10,
): Promise<{
totalCount: number;
anomalies: { key: string; anomalies: Anomaly[] }[];
}> {
const activeAnomalies = await this.findActiveAnomalies(
undefined,
undefined,
[
AnomalyTypes.DriverCertificate,
AnomalyTypes.EmployeesLoggedIn,
AnomalyTypes.OneTourAtATime,
],
);
const groupedAnomalies = this.groupAnomaliesByGroupKey(activeAnomalies);
return {
totalCount: Object.keys(groupedAnomalies).length,
anomalies: Object.entries(groupedAnomalies)
.map(([key, anomalies]) => ({
key,
anomalies,
}))
.slice(skip, skip + take),
};
}
findById(id: string): Promise<Anomaly> {
return this.prisma.anomaly.findUnique({
where: {
id,
},
});
}
findSolutionTargetsByAnomalyId(anomalyId: string): Promise<SolutionTarget[]> {
return this.prisma.solutionTarget.findMany({
where: {
anomalyId,
},
});
}
findSolutionTargetStepsBySolutionTargetId(
solutionTargetId: string,
): Promise<SolutionTargetStep[]> {
return this.prisma.solutionTargetStep.findMany({
where: {
solutionTargetId,
},
});
}
findTourByOperationId(operationId: string): Promise<Tour> {
return this.prisma.tour.findUnique({ where: { operationId } });
}
async setAnomalyToSleep(anomalyId: string): Promise<Anomaly> {
await this.prisma.trace.create({
data: {
entityKey: anomalyId,
type: TraceTypes.AnomalyIgnored,
},
});
return this.prisma.anomaly.update({
where: {
id: anomalyId,
},
data: {
sleepSince: new Date(),
},
});
}
async acceptAnomaly(anomalyId: string): Promise<Anomaly> {
await this.prisma.trace.create({
data: {
entityKey: anomalyId,
type: TraceTypes.AnomalyAccepted,
},
});
return this.prisma.anomaly.update({
where: {
id: anomalyId,
},
data: {
resolvedAt: new Date(),
manuallyResolved: true,
},
});
}
private getWhereActiveAnomalyQuery(
types: AnomalyTypes[] = [],
): Prisma.AnomalyWhereInput {
return {
...(types.length ? { type: { in: types } } : {}),
resolvedAt: null,
OR: [
{
sleepSince: null,
},
{
sleepSince: {
lt: new Date(new Date().getTime() - 60 * 60 * 1000),
},
},
],
};
}
private groupAnomaliesByGroupKey(
anomalies: Anomaly[],
): Record<string, Anomaly[]> {
return anomalies.reduce(
(grouped, anomaly) => ({
...grouped,
[anomaly.groupKey]: [...(grouped[anomaly.groupKey] ?? []), anomaly],
}),
{} as Record<string, Anomaly[]>,
);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { AnomaliesService } from './anomalies.service';
import { AnomalyDetectorService } from './anomaly-detectors/anomaly-detector.service';
@Module({
imports: [PrismaModule],
providers: [AnomaliesService, AnomalyDetectorService],
exports: [AnomaliesService],
})
export class AnomalyDetectionModule {}
@@ -0,0 +1,36 @@
import { Anomaly, Prisma } from '@prisma/client';
import { Interval } from 'date-fns';
import { AnomalyDetectorService } from './anomaly-detector.service';
export type AnomalySkeleton = Pick<
Prisma.AnomalyCreateInput,
'createdAt' | 'workedOnBy' | 'sleepTimer' | 'sleepSince'
>;
export abstract class AnomalyDetector {
constructor(protected readonly dataService: AnomalyDetectorService) {}
/**
* Unique identifier of the Anomaly Type which is implemented by the class
* TODO: Make the type typesafe
*/
abstract readonly type: string;
/**
* Generic method call to detect anomalies in the data
*/
abstract detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]>;
/**
* Generic method call to validate if an anomaly has been solved
*/
abstract validate(anomaly: Anomaly): Promise<Anomaly>;
abstract createDescription(data: unknown): string;
abstract createSolution(data: unknown): Prisma.SolutionTargetCreateInput[];
protected getAnomalySkeleton(): AnomalySkeleton {
return {
createdAt: new Date(),
workedOnBy: '',
sleepTimer: 60 * 60 * 1000,
sleepSince: null,
};
}
}
@@ -0,0 +1,347 @@
import { Injectable } from '@nestjs/common';
import {
AttendanceRegistration,
Employee,
PlannedTime,
Prisma,
Tour,
WorkTime,
} from '@prisma/client';
import { Interval, addDays, addHours, endOfDay, startOfDay } from 'date-fns';
import { AttendanceRegistrationEvents } from 'src/core/base/attendance-registration-events.enum';
import { PrismaService } from 'src/core/database/prisma.service';
export type AnomalySkeleton = Pick<
Prisma.AnomalyCreateInput,
| 'createdAt'
| 'workedOnBy'
| 'sleepTimer'
| 'sleepSince'
| 'resolvedAt'
| 'manuallyResolved'
>;
@Injectable()
export class AnomalyDetectorService {
constructor(private readonly prisma: PrismaService) {}
/**
* Find a list of tours in a given time interval
* @param interval An interval defining the time range of tours
* @returns
*/
public findToursInInterval(
{ start, end }: Interval,
ordinanceTypes: string[] = [],
): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(start),
},
},
{
startDate: {
lt: new Date(end),
},
},
{
check: {
lte: 2,
},
},
ordinanceTypes.length
? { ordinanceType: { in: ordinanceTypes } }
: {},
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
deletedAt: null,
},
{
OR: [
{
patientId: null,
},
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
public findToursInIntervalWithEmployee(
{ start, end }: Interval,
employeeId: string,
ordinanceType?: string,
): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
OR: [
{
driverId: employeeId,
},
{
codriverId: employeeId,
},
],
},
{
startDate: {
gte: new Date(start),
},
},
{
startDate: {
lt: new Date(end),
},
},
...(ordinanceType ? [{ ordinanceType }] : []),
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
deletedAt: null,
},
{
OR: [
{
patientId: null,
},
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
public findTourByOperationId(operationId): Promise<Tour> {
return this.prisma.tour.findUnique({ where: { operationId } });
}
public findWorkTimeByPersonnelNumberForDay(
personnelNumber: string,
day: Date,
): Promise<WorkTime | undefined> {
let startOfGivenDay: Date;
if (day.getHours() >= 0 && day.getHours() < 3) {
startOfGivenDay = addDays(day, -1);
} else {
startOfGivenDay = startOfDay(day);
}
const endOfGivenDay = endOfDay(startOfGivenDay);
return this.prisma.workTime.findFirst({
where: {
userId: personnelNumber,
AND: [
{
startDate: {
gte: startOfGivenDay,
},
},
{
startDate: {
lte: endOfGivenDay,
},
},
],
},
});
}
public findAttendanceRegistrationByPersonnelNumberForShift(
personnelNumber: string,
day: Date,
eventTypes: AttendanceRegistrationEvents[] = [
AttendanceRegistrationEvents.ClockedIn,
AttendanceRegistrationEvents.ClockedOut,
],
): Promise<AttendanceRegistration[]> {
let startOfGivenDay: Date;
if (day.getHours() >= 0 && day.getHours() < 3) {
startOfGivenDay = addDays(day, -1);
} else {
startOfGivenDay = startOfDay(day);
}
const endOfShift = addHours(day, 12);
return this.prisma.attendanceRegistration.findMany({
where: {
employeeId: +personnelNumber,
event: {
in: eventTypes,
},
AND: [
{
dateTime: {
gte: startOfGivenDay,
},
},
{
dateTime: {
lt: endOfShift,
},
},
],
},
orderBy: {
dateTime: 'asc',
},
});
}
public findPlannedTimeByPersonnelNumberForDay(
personnelNumber: string,
day: Date,
): Promise<PlannedTime[]> {
let startOfGivenDay: Date;
if (day.getHours() >= 0 && day.getHours() < 3) {
startOfGivenDay = addDays(day, -1);
} else {
startOfGivenDay = startOfDay(day);
}
const endOfShift = addHours(day, 12);
return this.prisma.plannedTime.findMany({
where: {
userId: personnelNumber,
AND: [
{
startDate: {
gte: startOfGivenDay,
},
},
{
startDate: {
lt: endOfShift,
},
},
],
},
});
}
public findAttendanceRegistrationById(
attendanceRegistrationId: number,
): Promise<AttendanceRegistration> {
return this.prisma.attendanceRegistration.findUnique({
where: {
attendanceRegistrationId,
},
});
}
public findAttendanceRegistrationInInterval(
interval: Interval,
eventTypes: AttendanceRegistrationEvents[] = [
AttendanceRegistrationEvents.ClockedIn,
AttendanceRegistrationEvents.ClockedOut,
],
): Promise<AttendanceRegistration[]> {
return this.prisma.attendanceRegistration.findMany({
where: {
event: {
in: eventTypes,
},
AND: [
{
dateTime: {
gte: startOfDay(interval.start),
},
},
{
dateTime: {
lt: endOfDay(interval.end),
},
},
],
},
orderBy: {
dateTime: 'asc',
},
});
}
public findEmployeeById(employeeId: string): Promise<Employee> {
return this.prisma.employee.findUnique({ where: { id: employeeId } });
}
public findEmployeeByPersonnelNumber(
personnelNumber: string,
): Promise<Employee> {
return this.prisma.employee.findFirst({ where: { personnelNumber } });
}
}
@@ -0,0 +1,7 @@
export enum AnomalyTypes {
DriverCodriverSame = 'DriverCodriverSame',
DriverCertificate = 'DriverCertificate',
EmployeesLoggedIn = 'EmployeesLoggedIn',
OneTourAtATime = 'OneTourAtATime',
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
}
@@ -0,0 +1,174 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Prisma } from '@prisma/client';
import { Interval, format } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
export class DriverCertificateDetector extends AnomalyDetector {
private readonly logger = new Logger(DriverCertificateDetector.name);
readonly type = AnomalyTypes.DriverCertificate;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const toursInRange = await this.dataService.findToursInInterval(inInterval);
const anomalies = await Promise.all(
toursInRange
.filter(({ driverId }) => !!driverId)
.map(
async ({ driverId, startDate, driverName, carName, operationId }) => {
try {
const driverEmployee =
await this.dataService.findEmployeeById(driverId);
if (!driverEmployee) {
return;
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
carName,
driverName ?? '<Fahrer gelöscht>',
new Date(startDate),
);
}
if (
!this.isPersonTransportCertificateValidAtStartOfTour(
driverEmployee.personTransportCertificate,
startDate,
)
) {
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
carName,
driverName,
new Date(startDate),
);
}
} catch (error) {
this.logger.error(error);
return;
}
},
),
);
return anomalies.filter((anomaly) => !!anomaly);
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId] = anomaly.id.split('-');
try {
const tour = await this.dataService.findTourByOperationId(operationId);
if (!tour.driverId) {
return { ...anomaly };
}
const employee = await this.dataService.findEmployeeById(tour.driverId);
if (!employee) {
return { ...anomaly };
}
if (
this.isPersonTransportCertificateValidAtStartOfTour(
employee.personTransportCertificate,
tour.startDate,
)
) {
return {
...anomaly,
resolvedAt: new Date(),
};
} else {
return {
...anomaly,
};
}
} catch (error) {
this.logger.error(error, anomaly.id);
return anomaly;
}
}
createDescription({
name,
carName,
}: {
name: string;
carName: string;
}): string {
return `${carName}: ${name} hat keinen gültigen P-Schein`;
}
createSolution({
carName,
operationId,
startDate,
}: {
carName: string;
operationId: string;
startDate: Date;
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: `Fahrzeug ${carName}`,
steps: {
create: [
{
description: `Klären Sie ob im Fahrzeug ein Fahrer mit P-Schein sitzt`,
},
{
description: `Informieren Sie das Team des Fahrzeugs, dass sie sich neu anmelden müssen`,
},
],
},
},
{
name: 'DispoLive',
steps: {
create: [
{
description: `Passen Sie den Fahrer und Beifahrer der Tour ${operationId} an. Startzeit: ${format(
startDate,
'HH:mm dd.MM.yy',
)}`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
operationId: string,
carName: string,
name: string,
startDate: Date,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: `${format(
new Date(startDate),
'yyyy-MM-dd-HH-mm',
)}-${operationId}`,
groupDescription: `${operationId} - ${format(
toZonedTime(new Date(startDate), 'Europe/Berlin'),
'HH:mm dd.MM.yy',
)}`,
solution: {
create: this.createSolution({ carName, operationId, startDate }),
},
description: this.createDescription({ name, carName }),
};
}
private isPersonTransportCertificateValidAtStartOfTour(
personTransportCertificateValidUntil: Date,
startDateOfTour: Date,
): boolean {
return (
new Date(personTransportCertificateValidUntil).getTime() >=
new Date(startDateOfTour).getTime()
);
}
}
@@ -0,0 +1,92 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Prisma } from '@prisma/client';
import { Interval, format } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
export class DriverCodriverSameDetector extends AnomalyDetector {
private readonly logger = new Logger(DriverCodriverSameDetector.name);
readonly type = AnomalyTypes.DriverCodriverSame;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const toursInRange = await this.dataService.findToursInInterval(
inInterval,
['KTW'],
);
const anomalies = await Promise.all(
toursInRange.map(
({ operationId, carName, startDate, driverId, codriverId }) => {
if (driverId === codriverId) {
return this.createAnomaly(
`${operationId}-${this.type}`,
operationId,
carName,
startDate,
);
}
},
),
);
return anomalies.filter((anomaly) => !!anomaly);
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId] = anomaly.id.split('-');
const tour = await this.dataService.findTourByOperationId(operationId);
if (tour?.driverId !== tour?.codriverId) {
return {
...anomaly,
resolvedAt: new Date(),
};
}
return { ...anomaly };
}
createDescription({ carName }: { carName: string }): string {
return `${carName}: Fahrer und Beifahrer identisch`;
}
createSolution({
carName,
}: {
carName: string;
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: `Fahrzeug ${carName}`,
steps: {
create: [
{
description: `Stellen Sie sicher, dass im Fahrzeug zwei Personen sitzen.`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
operationId: string,
carName: string,
startDate: Date,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: `${format(
new Date(startDate),
'yyyy-MM-dd-HH-mm',
)}-${operationId}`,
groupDescription: `${operationId} - ${format(
toZonedTime(new Date(startDate), 'Europe/Berlin'),
'HH:mm dd.MM.yy',
)}`,
solution: { create: this.createSolution({ carName }) },
description: this.createDescription({ carName }),
};
}
}
@@ -0,0 +1,346 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Employee, Prisma, Tour } from '@prisma/client';
import {
Interval,
addMinutes,
format,
isBefore,
isWithinInterval,
set,
} from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
export class EmployeesLoggedInDetector extends AnomalyDetector {
private readonly logger = new Logger(EmployeesLoggedInDetector.name);
readonly type = AnomalyTypes.EmployeesLoggedIn;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const toursInRange = await this.dataService.findToursInInterval(inInterval);
const anomaliesByTour = await Promise.all(
toursInRange.map(async (tour) => {
return Promise.all([
this.detectAnomalyForEmployee(tour, 'driver'),
this.detectAnomalyForEmployee(tour, 'codriver'),
]);
}),
);
return anomaliesByTour.filter((anomaly) => !!anomaly).flat();
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId, ...remainingIdParts] = anomaly.id.split('-');
try {
const employeeId = remainingIdParts.slice(0, -1).join('-');
const tour = await this.dataService.findTourByOperationId(operationId);
const employee = await this.dataService.findEmployeeById(employeeId);
const startOfTour = this.getStartOfTour(
new Date(tour.startDate),
[
tour.startBegin,
tour.startEnd,
tour.target,
tour.targetBegin,
tour.empty,
].filter((val) => !!val)[0],
);
if (!startOfTour) return anomaly;
if (this.validateEmployee(employee)) {
const [isEmployeeLoggedIn] = await this.isEmployeeLoggedInAtDateTime(
employee.personnelNumber,
startOfTour,
);
if (isEmployeeLoggedIn) {
return {
...anomaly,
resolvedAt: new Date(),
};
} else {
return { ...anomaly };
}
} else {
return anomaly;
}
} catch (error) {
this.logger.error(error, anomaly.id);
return anomaly;
}
}
createDescription(name: string): string {
return `${name} ist nicht in der Zeiterfassung angemeldet`;
}
createSolution({
name,
mobile,
startOfTour,
attendanceInterval,
}: {
name: string;
mobile: string;
startOfTour: Date;
attendanceInterval: Interval;
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: 'Mitarbeiter',
steps: {
create: [
{
description: `Informieren Sie ${name}, dass er sich einstempeln muss. ${
mobile ? `(${mobile})` : ''
}`,
},
{
description: `Zeitpunkt der Tour Anfahrt: ${format(
toZonedTime(startOfTour, 'Europe/Berlin'),
'HH:mm',
)}`,
},
...(attendanceInterval
? [
{
description: `Registrierte Anwesenheit: ${format(
toZonedTime(attendanceInterval.start, 'Europe/Berlin'),
'HH:mm',
)}${
attendanceInterval.end
? ` - ${format(
toZonedTime(
attendanceInterval.end,
'Europe/Berlin',
),
'HH:mm',
)}`
: ''
}`,
},
]
: []),
],
},
},
{
name: 'Dyflexis',
steps: {
create: [
{
description: `Korrigieren Sie den Zeitstempel auf den Arbeitsbeginn des Mitarbeiters`,
},
],
},
},
];
}
private async detectAnomalyForEmployee(
{
driverId,
codriverId,
startDate,
startBegin,
startEnd,
target,
targetBegin,
empty,
operationId,
}: Tour,
driverType: 'driver' | 'codriver',
): Promise<Prisma.AnomalyCreateInput | undefined> {
const employeeId = driverType === 'driver' ? driverId : codriverId;
if (!employeeId) {
return Promise.resolve(undefined);
}
const employee = await this.dataService.findEmployeeById(employeeId);
if (!employee) return;
const startOfTour = this.getStartOfTour(
new Date(startDate),
[startBegin, startEnd, target, targetBegin, empty].filter(
(val) => !!val,
)[0],
);
if (!startOfTour) return Promise.resolve(undefined);
if (this.validateEmployee(employee)) {
const [isEmployeeLoggedIn, checkedInterval] =
await this.isEmployeeLoggedInAtDateTime(
employee.personnelNumber,
startOfTour,
);
if (!isEmployeeLoggedIn) {
const employeeName = `${employee.name} ${employee.surname}`;
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
employeeName,
employee.mobile,
startDate,
startOfTour,
checkedInterval,
);
}
}
return Promise.resolve(undefined);
}
private validateEmployee(employee: Employee | undefined) {
if (!employee) {
this.logger.warn(`Employee does not exist`);
return false;
}
if (!employee.personnelNumber) {
this.logger.warn(
`Employee has no personnelNumber`,
employee.id,
employee.surname,
employee.name,
);
return false;
}
return true;
}
private createAnomaly(
anomalyId: string,
operationId: string,
name: string,
mobile: string,
startDate: Date,
startOfTour: Date,
attendanceInterval: Interval,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: `${format(
new Date(startDate),
'yyyy-MM-dd-HH-mm',
)}-${operationId}`,
groupDescription: `${operationId} - ${format(
toZonedTime(new Date(startDate), 'Europe/Berlin'),
'HH:mm dd.MM.yy',
)}`,
solution: {
create: this.createSolution({
name,
mobile,
startOfTour,
attendanceInterval,
}),
},
description: this.createDescription(name),
};
}
private async isEmployeeLoggedInAtDateTime(
personnelNumber: string,
datetimeToCheck: Date,
): Promise<[boolean, Interval]> {
const workTime = await this.dataService.findWorkTimeByPersonnelNumberForDay(
personnelNumber,
datetimeToCheck,
);
const attendanceRegistrations =
await this.dataService.findAttendanceRegistrationByPersonnelNumberForShift(
personnelNumber,
datetimeToCheck,
);
const firstClockedIn = attendanceRegistrations.filter(
({ event }) => event === 'clocked in',
)[0];
const lastClockedOut = attendanceRegistrations
.filter(({ event }) => event === 'clocked out')
.reverse()[0];
const datetimeToCheckWithBuffer = addMinutes(datetimeToCheck, 5);
if (personnelNumber === '23') {
console.log(
datetimeToCheck,
{
start: new Date(workTime.startDate),
end: new Date(workTime.endDate),
},
{
start: new Date(firstClockedIn.dateTime),
end: new Date(lastClockedOut.dateTime),
},
);
}
try {
if (workTime) {
const interval = {
start: new Date(workTime.startDate),
end: new Date(workTime.endDate),
};
return [
isWithinInterval(datetimeToCheckWithBuffer, interval) ||
isWithinInterval(datetimeToCheck, interval),
interval,
];
} else if (
firstClockedIn &&
lastClockedOut &&
isBefore(lastClockedOut.dateTime, firstClockedIn.dateTime)
) {
return [
false,
{
start: firstClockedIn.dateTime,
end: undefined,
},
];
} else if (firstClockedIn && lastClockedOut) {
const interval = {
start: new Date(firstClockedIn.dateTime),
end: new Date(lastClockedOut.dateTime),
};
return [
isWithinInterval(datetimeToCheckWithBuffer, interval) ||
isWithinInterval(datetimeToCheck, interval),
interval,
];
} else if (firstClockedIn && !lastClockedOut) {
return [
isBefore(new Date(firstClockedIn.dateTime), datetimeToCheck) ||
isBefore(
new Date(firstClockedIn.dateTime),
datetimeToCheckWithBuffer,
),
{ start: firstClockedIn.dateTime, end: undefined },
];
} else {
return [false, undefined];
}
} catch (error) {
this.logger.error(
error,
datetimeToCheck.toISOString(),
workTime,
firstClockedIn,
lastClockedOut,
);
return [false, undefined];
}
}
private getStartOfTour(startDate: Date, startBegin: string) {
if (!startBegin) return null;
const zonedTime = toZonedTime(startDate, 'Europe/Berlin');
const [hours, minutes] = startBegin.split(':').map((v) => +v);
return fromZonedTime(set(zonedTime, { hours, minutes }), 'Europe/Berlin');
}
}
@@ -0,0 +1,371 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Prisma } from '@prisma/client';
import {
addDays,
areIntervalsOverlapping,
endOfDay,
format,
Interval,
isBefore,
isWithinInterval,
set,
startOfDay,
} from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
export class OneTourAtATimeDetector extends AnomalyDetector {
private readonly logger = new Logger(OneTourAtATimeDetector.name);
readonly type = AnomalyTypes.OneTourAtATime;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const toursInRange = await this.dataService.findToursInInterval(inInterval);
return toursInRange
.map(
(
{
operationId,
startDate,
startBegin,
startEnd,
targetBegin,
target,
empty,
carName,
driverId,
driverName,
codriverId,
codriverName,
},
_,
tours,
) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [start] = [startBegin, startEnd].sort();
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
const [end] = [targetBegin, target, empty]
.filter((val) => !!val)
.sort()
.reverse();
if (!start || !end) {
if (isBefore(new Date(), startDate)) {
this.logger.warn(
`Tour ${operationId} started in the past, but is missing start or end time`,
);
}
return [];
}
const tourInterval = this.getTourInterval(start, end, startDate);
const toursInInterval = tours
.filter((tour) =>
isWithinInterval(tour.startDate, {
start: startOfDay(startDate),
end: endOfDay(startDate),
}),
)
.filter(
({
operationId: tourListOperationId,
carName: tourListCarName,
}) =>
tourListOperationId !== operationId &&
carName !== tourListCarName,
)
.filter(
({
startDate: tourListStartDate,
startBegin: tourListStartBegin,
startEnd: tourListStartEnd,
targetBegin: tourListTargetBegin,
target: tourListTarget,
empty: tourListEmpty,
}) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [tourListStart] = [
tourListStartBegin,
tourListStartEnd,
].sort();
// somehow empty can be before everything else, so we use targetBegin and empty and take the higher value ...
const [tourListEnd] = [
tourListTargetBegin,
tourListTarget,
tourListEmpty,
]
.filter((val) => !!val)
.sort()
.reverse();
if (!tourListStart || !tourListEnd) {
return false;
}
const tourListInterval = this.getTourInterval(
tourListStart,
tourListEnd,
tourListStartDate,
);
try {
return areIntervalsOverlapping(
tourInterval,
tourListInterval,
);
} catch (error) {
this.logger.error(
'tourListInterval',
error,
tourListInterval,
tourInterval,
);
return false;
}
},
);
const toursInIntervalWithDriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
driverId === tourListDriverId || driverId === tourListCodriverId,
);
const toursInIntervalWithCodriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
codriverId === tourListDriverId ||
codriverId === tourListCodriverId,
);
const overlappingCarsWithDriver = toursInIntervalWithDriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithDriver = toursInIntervalWithDriver.map(
({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}),
);
const overlappingCarsWithCodriver = toursInIntervalWithCodriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithCodriver =
toursInIntervalWithCodriver.map(({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}));
return [
toursInIntervalWithDriver.length
? this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
driverName,
startDate,
[carName, ...overlappingCarsWithDriver],
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
},
...overlappingTourInfoWithDriver,
],
)
: undefined,
toursInIntervalWithCodriver.length
? this.createAnomaly(
`${operationId}-${codriverId}-${this.type}`,
operationId,
codriverName,
startDate,
[carName, ...overlappingCarsWithCodriver],
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
},
...overlappingTourInfoWithCodriver,
],
)
: undefined,
];
},
)
.flat()
.filter((anomaly) => !!anomaly);
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId, ...remainingIdParts] = anomaly.id.split('-');
const employeeId = remainingIdParts.slice(0, -1).join('-');
try {
const tour = await this.dataService.findTourByOperationId(operationId);
const { startBegin, startEnd, target, targetBegin, empty, startDate } =
tour;
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [start] = [startBegin, startEnd].sort();
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
const [end] = [targetBegin, target, empty]
.filter((val) => !!val)
.sort()
.reverse();
const tourInterval: Interval = this.getTourInterval(
start,
end,
startDate,
);
const startOfDayWithTour = startOfDay(startDate);
const endOfDayWithTour = endOfDay(startDate);
const toursWithEmployee =
await this.dataService.findToursInIntervalWithEmployee(
{ start: startOfDayWithTour, end: endOfDayWithTour },
employeeId,
'KTW',
);
if (
!toursWithEmployee
.filter(
({ operationId: tourListOperationId }) =>
operationId !== tourListOperationId,
)
.some(
({
startDate: tourListStartDate,
startBegin: tourListStartBegin,
startEnd: tourListStartEnd,
targetBegin: tourListTargetBegin,
target: tourListTarget,
empty: tourListEmpty,
}) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [start] = [tourListStartBegin, tourListStartEnd].sort();
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
const [end] = [tourListTargetBegin, tourListTarget, tourListEmpty]
.filter((val) => !!val)
.sort()
.reverse();
const tourListTourInterval = this.getTourInterval(
start,
end,
tourListStartDate,
);
return areIntervalsOverlapping(
tourInterval,
tourListTourInterval,
);
},
)
) {
return {
...anomaly,
resolvedAt: new Date(),
};
}
return { ...anomaly };
} catch (error) {
this.logger.error(error, anomaly.id);
return anomaly;
}
}
createDescription({ name }: { name: string }): string {
return `${name} ist auf mehr als einer Tour gleichzeitig`;
}
createSolution({
name,
carNames,
tourInfo,
}: {
name: string;
carNames: string[];
tourInfo: { operationId: string; startDate: string }[];
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: `Mitarbeiter ${name}`,
steps: {
create: [
{
description: `Sprechen Sie mit ${name} und klären Sie in welchem Fahrzeug er ist`,
},
{
description: `Informieren Sie das Team des Fahrzeugs auf dem der Mitarbeiter nicht ist, dass sie sich neu anmelden müssen`,
},
{
description: `Fahrzeuge mit diesem Mitarbeiter: ${carNames.join(
', ',
)}`,
},
],
},
},
{
name: 'DispoLive',
steps: {
create: [
{
description: `Prüfen Sie ob bei den hier aufgelisteten Touren der Fahrer und Beifahrer richtig eingetragen sind`,
},
...tourInfo.map(({ operationId, startDate }) => ({
description: `Einsatznummer: ${operationId} - ${startDate}`,
})),
],
},
},
];
}
private createAnomaly(
anomalyId: string,
operationId: string,
name: string,
startDate,
carNames: string[],
tourInfo: { operationId: string; startDate: string }[],
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: `${format(
new Date(startDate),
'yyyy-MM-dd-HH-mm',
)}-${operationId}`,
groupDescription: `${operationId} - ${format(
toZonedTime(new Date(startDate), 'Europe/Berlin'),
'HH:mm dd.MM.yy',
)}`,
solution: {
create: this.createSolution({ name, carNames, tourInfo }),
},
description: this.createDescription({ name }),
};
}
private getTourInterval(
startTime: string,
endTime: string,
startDate: Date | string,
): Interval {
return {
start: this.getStartOfTour(new Date(startDate), startTime),
end: this.getStartOfTour(new Date(startDate), endTime),
};
}
private getStartOfTour(startDate: Date, startBegin: string) {
if (!startBegin) return null;
const zonedTime = toZonedTime(startDate, 'Europe/Berlin');
const [hours, minutes] = startBegin.split(':').map((v) => +v);
let baseDate = new Date(zonedTime);
if (hours >= 0 && hours < 3 && startDate.getHours() > 3) {
baseDate = addDays(baseDate, 1);
}
return fromZonedTime(set(baseDate, { hours, minutes }), 'Europe/Berlin');
}
}
@@ -0,0 +1,171 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Prisma } from '@prisma/client';
import { Interval, endOfDay, format, startOfDay } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { AttendanceRegistrationEvents } from 'src/core/base/attendance-registration-events.enum';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
export class WorkingEmployeeNotInDistributionSystemDetector extends AnomalyDetector {
private readonly logger = new Logger(
WorkingEmployeeNotInDistributionSystemDetector.name,
);
readonly type = AnomalyTypes.WorkingEmployeeNotInDistributionSystem;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const attendanceRegistrationInInterval =
await this.dataService.findAttendanceRegistrationInInterval(inInterval, [
AttendanceRegistrationEvents.ClockedIn,
]);
const anomalies = await Promise.all(
attendanceRegistrationInInterval.map(
async ({
attendanceRegistrationId,
// As worktime and plannedtime use the internal employeeId of dyflexis we use it as the personnelNumber entered in dispoLive
employeeId: personnelNumber,
dateTime,
}) => {
try {
const employee =
await this.dataService.findEmployeeByPersonnelNumber(
`${personnelNumber}`,
);
if (!employee) return;
const plannedTime =
await this.dataService.findPlannedTimeByPersonnelNumberForDay(
`${personnelNumber}`,
dateTime,
);
if (
plannedTime.find(
({ department }) =>
department.toLowerCase() == 'Büro'.toLowerCase() ||
department.toLowerCase() == 'Leitstelle'.toLowerCase(),
)
) {
return;
}
const toursWithEmployee =
await this.dataService.findToursInIntervalWithEmployee(
{
start: startOfDay(new Date(dateTime)),
end: endOfDay(new Date(dateTime)),
},
employee.id,
);
if (toursWithEmployee.length === 0) {
return this.createAnomaly(
`${attendanceRegistrationId}-${employee.id}`,
format(new Date(dateTime), 'yyyy-MM-dd'),
`${employee.name} ${employee.surname}`,
);
}
} catch (error) {
this.logger.warn(
`Employee does not exist! PersonnelNumber: ${personnelNumber}`,
error,
);
return;
}
},
),
);
return anomalies.filter((anomaly) => !!anomaly);
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [attendanceRegistrationId, ...employeeIdParts] =
anomaly.id.split('-');
const attendanceRegistration =
await this.dataService.findAttendanceRegistrationById(
+attendanceRegistrationId,
);
const employeeId = employeeIdParts.join('-');
const employee = await this.dataService.findEmployeeById(employeeId);
const plannedTime =
await this.dataService.findPlannedTimeByPersonnelNumberForDay(
`${employee.personnelNumber}`,
attendanceRegistration.dateTime,
);
if (
plannedTime.find(
({ department }) =>
department.toLowerCase() == 'Büro'.toLowerCase() ||
department.toLowerCase() == 'Leitstelle'.toLowerCase(),
)
) {
return {
...anomaly,
resolvedAt: new Date(),
};
}
const toursWithEmployee =
await this.dataService.findToursInIntervalWithEmployee(
{
start: startOfDay(new Date(attendanceRegistration.dateTime)),
end: endOfDay(new Date(attendanceRegistration.dateTime)),
},
employee.id,
);
if (toursWithEmployee.length > 0) {
return {
...anomaly,
resolvedAt: new Date(),
};
}
return {
...anomaly,
};
}
createDescription(name: string): string {
return `${name} ist keinem Fahrzeug zugewiesen`;
}
createSolution(date: string): Prisma.SolutionTargetCreateInput[] {
return [
{
name: `Mitarbeiter ${date}`,
steps: {
create: [
{
description:
'Ist der Mitarbeiter teil des Fahrerteams, dann informieren Sie ihn, dass er sich am Fahrzeug anmelden muss',
},
{
description:
'Ist der Mitarbeiter nicht teil des Fahrerteams, akzeptieren Sie diese Anomalie',
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
dateString: string,
name: string,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: dateString,
groupDescription: `${format(toZonedTime(new Date(dateString), 'Europe/Berlin'), 'dd.MM.yy')}`,
solution: {
create: this.createSolution(
format(
toZonedTime(new Date(dateString), 'Europe/Berlin'),
'dd.MM.yy',
),
),
},
description: this.createDescription(name),
};
}
}