add business model processing
This commit is contained in:
@@ -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[]>,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -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 {}
|
||||
+36
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+347
@@ -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 } });
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export enum AnomalyTypes {
|
||||
DriverCodriverSame = 'DriverCodriverSame',
|
||||
DriverCertificate = 'DriverCertificate',
|
||||
EmployeesLoggedIn = 'EmployeesLoggedIn',
|
||||
OneTourAtATime = 'OneTourAtATime',
|
||||
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
|
||||
}
|
||||
+174
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+92
@@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
+346
@@ -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');
|
||||
}
|
||||
}
|
||||
+371
@@ -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');
|
||||
}
|
||||
}
|
||||
+171
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user