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),
};
}
}
@@ -0,0 +1,110 @@
export const BILLING_INFORMATION_BY_ORDINANCE_TYPE = {
BTW: {
'Mo-Fr': {
'03:00-11:59': {
personnelCostsPerHour: 20.23,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
'12:00-18:29': {
personnelCostsPerHour: 20.23,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
'18:30-02:59': {
personnelCostsPerHour: 24.59,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
},
Sa: {
'03:00-11:59': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
'12:00-18:29': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
'18:30-02:59': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 41.22,
},
},
},
TSW: {
'Mo-Fr': {
'03:00-11:59': {
personnelCostsPerHour: 20.23,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
'12:00-18:29': {
personnelCostsPerHour: 20.23,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
'18:30-02:59': {
personnelCostsPerHour: 24.59,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
},
Sa: {
'03:00-11:59': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
'12:00-18:29': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
'18:30-02:59': {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
revenuePerTour: 58.08,
},
},
},
KTW: {
'Mo-Fr': {
'03:00-11:59': {
personnelCostsPerHour: 56.36 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
'12:00-18:29': {
personnelCostsPerHour: 56.36 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
'18:30-02:59': {
personnelCostsPerHour: 68.5 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
},
Sa: {
'03:00-11:59': {
personnelCostsPerHour: 64.81 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
'12:00-18:29': {
personnelCostsPerHour: 64.81 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
'18:30-02:59': {
personnelCostsPerHour: 64.81 / 2,
consumptionCostsPerTour: 4.02,
revenuePerTour: 128.08,
},
},
},
};
@@ -0,0 +1,476 @@
import { Injectable, Logger } from '@nestjs/common';
import {
CostType,
CostTypeVariant,
Tariff,
TariffGroup,
Tour,
} from '@prisma/client';
import {
Interval,
addDays,
isSameDay,
isSaturday,
isWeekend,
isWithinInterval,
} from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { PrismaService } from 'src/core/database/prisma.service';
import { generateIntervalForTimeRange } from 'src/utils';
import { BILLING_INFORMATION_BY_ORDINANCE_TYPE } from './billing-information-by-ordinance-type';
import { INFECTION_TYPES } from './infection-types';
import { PersonnelCostCalculationService } from './personnel-cost-calculation.service';
import { PUBLIC_HOLIDAYS_HH } from './public-holidays';
enum OrdinanceTypes {
BTW = 'BTW',
TSW = 'TSW',
KTW = 'KTW',
}
@Injectable()
export class CalculationService {
private readonly logger = new Logger(CalculationService.name);
private healthInsurancesWithNoTenant = [];
constructor(
private readonly prisma: PrismaService,
private readonly personnelCostCalculationService: PersonnelCostCalculationService,
) {}
async calculateTourRevenue(tour: Tour): Promise<number> {
const { healthInsurance, ordinanceType, startDate } = tour;
if (!ordinanceType) {
return 0;
}
const tariff = await this.findTariff(
healthInsurance,
ordinanceType.match(/([A-Z])\w+/).at(0),
new Date(startDate),
);
if (!tariff) {
this.logger.error(
`No tariff found for tour ${tour.operationId} (${tour.healthInsurance})`,
);
return 0;
}
const revenue = this.calculateRevenue(tour, tariff) / 100;
return revenue;
}
public calculatePersonnelCostInInterval(
interval: Interval,
employeeAmount: number,
ordinanceType: string,
): number {
return this.personnelCostCalculationService.calculatePersonnelCostInInterval(
interval,
ordinanceType,
employeeAmount,
);
}
public getBillingInformationByOrdinanceType(
ordinanceType: string,
date: Date,
) {
if (isSaturday(date)) {
if (ordinanceType.indexOf('KTW') > -1) {
return {
personnelCostsPerHour: 64.81 / 2,
consumptionCostsPerTour: 4.02,
};
}
return {
personnelCostsPerHour: 23.27,
consumptionCostsPerTour: 3.68,
};
}
if (ordinanceType.indexOf('KTW') > -1) {
if (
isWithinInterval(
date,
generateIntervalForTimeRange('18:30-03:00', 'Europe/Berlin', date),
)
) {
return {
personnelCostsPerHour: 68.5 / 2,
consumptionCostsPerTour: 4.02,
};
}
return {
personnelCostsPerHour: 56.36 / 2,
consumptionCostsPerTour: 4.02,
};
}
if (
isWithinInterval(
date,
generateIntervalForTimeRange('18:30-03:00', 'Europe/Berlin', date),
)
) {
return {
personnelCostsPerHour: 24.59,
consumptionCostsPerTour: 3.68,
};
}
return {
personnelCostsPerHour: 20.23,
consumptionCostsPerTour: 3.68,
};
}
public getModellBillingInformationByOrdinanceType(
ordinanceType: string,
date: Date,
) {
const daysKey = isSaturday(date) ? 'Sa' : 'Mo-Fr';
if (
!Object.values(OrdinanceTypes).includes(ordinanceType as OrdinanceTypes)
) {
throw new Error(
`OrdinanceType not supported. Supported types ${Object.values(
OrdinanceTypes,
).join(', ')}, got ${ordinanceType}`,
);
}
const billingInformationsByIntervals =
BILLING_INFORMATION_BY_ORDINANCE_TYPE[ordinanceType][daysKey] as Record<
string,
{
personnelCostsPerHour: number;
consumptionCostsPerTour: number;
revenuePerTour: number;
}
>;
const billingInformation = Object.entries(
billingInformationsByIntervals,
).find(([timeRange]) =>
isWithinInterval(
date,
generateIntervalForTimeRange(
timeRange,
'Europe/Berlin',
date.getHours() >= 0 && date.getHours() <= 3
? addDays(date, -1)
: date,
),
),
);
if (!billingInformation) {
this.logger.error(
"Couldn't find billing information",
ordinanceType,
date.toISOString(),
);
throw new Error("Couldn't find billing information");
}
return billingInformation[1];
}
private calculateRevenue(
tour: Tour,
tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup },
): number {
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
// if TariffGroup is VDEK KTW, sum up costTypes
const isVdek =
tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 ?? false;
return tariff.costTypes.reduce((sum, costType) => {
switch (costType.variant) {
case CostTypeVariant.GRUNDPREIS:
// TODO: decide if you need a CostTypeVariant for QMS Zuschlag and exclude the category there
if (
costType.description.indexOf('QMS') > -1 &&
tour.category === 'Privatfahrt Rechnung'
) {
return sum;
}
return sum + costType.costAmount;
case CostTypeVariant.TSWDIFFERENZIERUNG:
return sum + this.calculateTSW(tour, costType);
case CostTypeVariant.DESINFEKTIONSZUSCHLAG:
return sum + this.calculateDesinfektionszuschlag(tour, costType);
case CostTypeVariant.KILOMETERZUSCHLAG:
return sum + this.calculateKilometerzuschlag(tour, costType);
case CostTypeVariant.FAHRZEUGZUSCHLAG:
return sum + this.calculateFahrzeugzuschlag(tour, costType);
case CostTypeVariant.NACHTZUSCHLAG:
if (highestZuschlag === CostTypeVariant.NACHTZUSCHLAG || isVdek) {
return sum + this.calculateNachtzuschlag(tour, costType);
}
return sum;
case CostTypeVariant.WOCHENENDZUSCHLAG:
if (highestZuschlag === CostTypeVariant.WOCHENENDZUSCHLAG || isVdek) {
return sum + this.calculateWochenendzuschlag(tour, costType);
}
return sum;
case CostTypeVariant.FEIERTAGSZUSCHLAG:
if (highestZuschlag === CostTypeVariant.FEIERTAGSZUSCHLAG || isVdek) {
return sum + this.calculateFeiertagszuschlag(tour, costType);
}
return sum;
}
}, 0);
}
private getHighestZuschlag(
tour: Tour,
tariff: Tariff & { costTypes: CostType[] },
): CostTypeVariant {
const zuschlaegeHighestToLowest = tariff.costTypes
.filter((costType) =>
[
CostTypeVariant.NACHTZUSCHLAG,
CostTypeVariant.FEIERTAGSZUSCHLAG,
CostTypeVariant.WOCHENENDZUSCHLAG,
].includes(costType.variant as any),
)
.map((costType): [CostTypeVariant, number] => {
switch (costType.variant) {
case CostTypeVariant.NACHTZUSCHLAG:
return [
CostTypeVariant.NACHTZUSCHLAG,
this.calculateNachtzuschlag(tour, costType),
];
case CostTypeVariant.WOCHENENDZUSCHLAG:
return [
CostTypeVariant.WOCHENENDZUSCHLAG,
this.calculateWochenendzuschlag(tour, costType),
];
case CostTypeVariant.FEIERTAGSZUSCHLAG:
return [
CostTypeVariant.FEIERTAGSZUSCHLAG,
this.calculateFeiertagszuschlag(tour, costType),
];
}
})
.sort(
([, costAmountA], [, costAmountB]) =>
(costAmountA as number) - (costAmountB as number),
)
.reverse();
if (zuschlaegeHighestToLowest.length === 0) {
return undefined;
}
return zuschlaegeHighestToLowest[0][0];
}
private calculateDesinfektionszuschlag(
tour: Tour,
costType: CostType,
): number {
if (tour.hasInfection && !tour.infectionName) {
return 0;
}
if (
tour.hasInfection &&
(tour.infectionName.indexOf(costType.infectionType) > -1 ||
INFECTION_TYPES[tour.infectionName] === costType.infectionType)
) {
return costType.costAmount;
}
return 0;
}
private calculateTSW(tour: Tour, costType: CostType): number {
if (
costType.description.includes('TSW 1') &&
tour.driverId === tour.codriverId
) {
return costType.costAmount;
} else if (
costType.description.includes('TSW 2') &&
tour.driverId !== tour.codriverId
) {
return costType.costAmount;
}
return 0;
}
private calculateFeiertagszuschlag(tour: Tour, costType: CostType): number {
if (
PUBLIC_HOLIDAYS_HH.some((day) =>
isSameDay(
new Date(day),
new Date(toZonedTime(tour.startDate, 'Europe/Berlin')),
),
)
) {
return costType.costAmount;
}
return 0;
}
private calculateNachtzuschlag(tour: Tour, costType: CostType): number {
const firstInterval = generateIntervalForTimeRange(
costType.timeRange,
'Europe/Berlin',
addDays(tour.startDate, -1),
);
const secondInterval = generateIntervalForTimeRange(
costType.timeRange,
'Europe/Berlin',
tour.startDate,
);
if (
isWithinInterval(tour.startDate, firstInterval) ||
isWithinInterval(tour.startDate, secondInterval)
) {
return costType.costAmount;
}
return 0;
}
private calculateKilometerzuschlag(tour: Tour, costType: CostType): number {
const kilometerOverLimit = Math.ceil(
tour.occupiedKm - costType.kmInclusive,
);
if (kilometerOverLimit <= 0) return 0;
return costType.costAmount * kilometerOverLimit;
}
private calculateFahrzeugzuschlag(tour: Tour, costType: CostType): number {
const isCarNameSupported = !costType.excludeCarNames.includes(tour.carName);
if (!isCarNameSupported) return 0;
return costType.costAmount;
}
private calculateWochenendzuschlag(tour: Tour, costType: CostType): number {
if (isWeekend(toZonedTime(tour.startDate, 'Europe/Berlin')))
return costType.costAmount;
return 0;
}
private async findTariff(
healthInsurance: string,
ordinanceType: string,
tourDate: Date,
) {
if (!healthInsurance || healthInsurance === '') {
return this.prisma.tariff.findFirst({
where: {
tariffGroup: {
description: {
contains: 'AOK',
},
ordinanceType,
},
OR: [
{
AND: [
{ validTo: null },
{
validFrom: {
lte: tourDate,
},
},
],
},
{
AND: [
{ validTo: { gte: tourDate } },
{ validFrom: { lte: tourDate } },
],
},
],
},
include: {
tariffGroup: true,
costTypes: true,
},
});
}
const tenant = await this.prisma.avicennaTenant.findFirst({
where: {
name: {
contains: healthInsurance,
},
tariffGroups: {
some: {
ordinanceType,
},
},
},
});
if (!tenant) {
this.healthInsurancesWithNoTenant.push(healthInsurance);
return this.prisma.tariff.findFirst({
where: {
tariffGroup: {
description: {
contains: 'AOK',
},
ordinanceType,
},
OR: [
{
AND: [
{ validTo: null },
{
validFrom: {
lte: tourDate,
},
},
],
},
{
AND: [
{ validTo: { gte: tourDate } },
{ validFrom: { lte: tourDate } },
],
},
],
},
include: {
tariffGroup: true,
costTypes: true,
},
});
}
return this.prisma.tariff.findFirst({
where: {
tariffGroup: {
tenants: {
some: {
id: tenant.id,
},
},
ordinanceType,
},
OR: [
{
AND: [
{ validTo: null },
{
validFrom: {
lte: tourDate,
},
},
],
},
{
AND: [
{ validTo: { gte: tourDate } },
{ validFrom: { lte: tourDate } },
],
},
],
},
include: {
tariffGroup: true,
costTypes: true,
},
});
}
getHealthInsurancesWithNoTenant(): string[] {
return [...new Set(this.healthInsurancesWithNoTenant)];
}
}
@@ -0,0 +1,22 @@
export const INFECTION_TYPES = {
'3 MRGN': '2.A',
'4 MRGN': '2.A',
Aids: '2.A',
Clostridien: '2.B',
'Clostridium diffcile': '2.B',
'Covid-19': '3.1',
Durchfall: '2.A',
'Durchfallerkrankungen/Salmonellosen': '2.A',
ESBL: '2.A',
'Extended-spectrum Beta-Laktamase': '2.A',
'Hepatitis B inkl. D': '2.A',
'Hepatitis B inklusive D': '2.A',
'Hepatitis C': '2.A',
HIV: '2.A',
Meningitis: '3.1',
'Methicillin resistente Staphylococcus Aureus': '2.A',
MRSA: '2.A',
Norovirus: '2.B',
Sonstiges: '2.A',
VRE: '2.A',
};
@@ -0,0 +1,175 @@
import { Injectable } from '@nestjs/common';
import { Interval, getISODay, isSameDay } from 'date-fns';
import { toZonedTime } from 'date-fns-tz';
import { generateIntervalForTimeRange, getHoursInInterval } from 'src/utils';
import { PUBLIC_HOLIDAYS_HH } from './public-holidays';
enum PersonnelCostCondition {
NACHTZUSCHLAG = 'NACHTZUSCHLAG',
SAMSTAGSZUSCHLAG = 'SAMSTAGSZUSCHLAG',
SONNTAGSZUSCHLAG = 'SONNTAGSZUSCHLAG',
FEIERTAGSZUSCHLAG = 'FEIERTAGSZUSCHLAG',
}
@Injectable()
export class PersonnelCostCalculationService {
private baseCost: Record<
'KTW' | 'OTHER',
{
base: number;
withAdditional: number;
}
> = {
KTW: {
base: 1733,
withAdditional: 2306,
},
OTHER: {
base: 1440,
withAdditional: 1797,
},
};
private costTypes = [
{
description: 'Nachtzuschlag von 20 Uhr bis 0 Uhr',
condition: PersonnelCostCondition.NACHTZUSCHLAG,
percent: 25,
timeRange: '20:00-23:59',
},
{
description: 'Nachtzuschlag von 0 Uhr bis 4 Uhr',
condition: PersonnelCostCondition.NACHTZUSCHLAG,
percent: 40,
timeRange: '00:00-04:00',
},
{
description: 'Samstagszuschlag',
condition: PersonnelCostCondition.SAMSTAGSZUSCHLAG,
percent: 15,
},
{
description: 'Sonntagszuschlag',
condition: PersonnelCostCondition.SONNTAGSZUSCHLAG,
percent: 50,
},
{
description: 'Feiertagszuschlag',
condition: PersonnelCostCondition.FEIERTAGSZUSCHLAG,
percent: 125,
},
];
calculatePersonnelCostInInterval(
interval: Interval,
ordinanceType: string,
employeeAmount: number,
): number {
const { base, withAdditional } =
this.getBaseCostForOrdinanceType(ordinanceType);
const baseCostInInterval = getHoursInInterval(interval) * withAdditional;
const additionalCosts = this.calculateAdditionalCosts(interval, base);
return (baseCostInInterval + additionalCosts) * employeeAmount;
}
calculateAdditionalCosts(interval: Interval, baseCost: number): number {
return this.costTypes
.map(({ condition, percent, timeRange }) => {
switch (condition) {
case PersonnelCostCondition.NACHTZUSCHLAG:
return this.calculateNachtZuschlag(
interval,
baseCost,
percent,
timeRange,
);
case PersonnelCostCondition.SAMSTAGSZUSCHLAG:
return this.calculateWeekdayZuschlag(
interval,
baseCost,
percent,
6,
);
case PersonnelCostCondition.SONNTAGSZUSCHLAG:
return this.calculateWeekdayZuschlag(
interval,
baseCost,
percent,
7,
);
case PersonnelCostCondition.FEIERTAGSZUSCHLAG:
return this.calculateFeiertagZuschlag(interval, baseCost, percent);
}
})
.reduce((sum, cost) => (sum += cost), 0);
}
private calculateNachtZuschlag(
interval: Interval,
baseCost: number,
percent: number,
timeRange: string,
): number {
const timeRangeIntervalForStart = generateIntervalForTimeRange(
timeRange,
'Europe/Berlin',
new Date(interval.start),
);
const timeRangeIntervalForEnd = generateIntervalForTimeRange(
timeRange,
'Europe/Berlin',
new Date(interval.end),
);
const costsForStart =
(getHoursInInterval(interval, timeRangeIntervalForStart) *
baseCost *
percent) /
100;
const costsForEnd =
(getHoursInInterval(interval, timeRangeIntervalForEnd) *
baseCost *
percent) /
100;
return costsForStart > costsForEnd ? costsForStart : costsForEnd;
}
private calculateWeekdayZuschlag(
interval: Interval,
baseCost: number,
percent: number,
weekday: number,
): number {
if (getISODay(toZonedTime(interval.start, 'Europe/Berlin')) === weekday) {
return (getHoursInInterval(interval) * baseCost * percent) / 100;
}
return 0;
}
private calculateFeiertagZuschlag(
interval: Interval,
baseCost: number,
percent: number,
): number {
if (
PUBLIC_HOLIDAYS_HH.some((day) =>
isSameDay(
new Date(day),
new Date(toZonedTime(interval.start, 'Europe/Berlin')),
),
)
) {
return (getHoursInInterval(interval) * baseCost * percent) / 100;
}
return 0;
}
private getBaseCostForOrdinanceType(ordinanceType: string): {
base: number;
withAdditional: number;
} {
if (ordinanceType.indexOf('KTW') > -1) {
return this.baseCost.KTW;
}
return this.baseCost.OTHER;
}
}
@@ -0,0 +1,112 @@
export const PUBLIC_HOLIDAYS_HH = [
'2020-01-01T00:00:00.000Z',
'2020-04-10T00:00:00.000Z',
'2020-04-13T00:00:00.000Z',
'2020-05-01T00:00:00.000Z',
'2020-05-21T00:00:00.000Z',
'2020-06-01T00:00:00.000Z',
'2020-10-03T00:00:00.000Z',
'2020-10-31T00:00:00.000Z',
'2020-12-25T00:00:00.000Z',
'2020-12-26T00:00:00.000Z',
'2021-01-01T00:00:00.000Z',
'2021-04-02T00:00:00.000Z',
'2021-04-05T00:00:00.000Z',
'2021-05-01T00:00:00.000Z',
'2021-05-13T00:00:00.000Z',
'2021-05-24T00:00:00.000Z',
'2021-10-03T00:00:00.000Z',
'2021-10-31T00:00:00.000Z',
'2021-12-25T00:00:00.000Z',
'2021-12-26T00:00:00.000Z',
'2022-01-01T00:00:00.000Z',
'2022-04-15T00:00:00.000Z',
'2022-04-18T00:00:00.000Z',
'2022-05-01T00:00:00.000Z',
'2022-06-06T00:00:00.000Z',
'2022-05-26T00:00:00.000Z',
'2022-10-03T00:00:00.000Z',
'2022-10-31T00:00:00.000Z',
'2022-12-25T00:00:00.000Z',
'2022-12-26T00:00:00.000Z',
'2023-01-01T00:00:00.000Z',
'2023-04-07T00:00:00.000Z',
'2023-04-10T00:00:00.000Z',
'2023-05-01T00:00:00.000Z',
'2023-05-18T00:00:00.000Z',
'2023-05-29T00:00:00.000Z',
'2023-10-03T00:00:00.000Z',
'2023-10-31T00:00:00.000Z',
'2023-12-25T00:00:00.000Z',
'2023-12-26T00:00:00.000Z',
'2024-01-01T00:00:00.000Z',
'2024-03-29T00:00:00.000Z',
'2024-04-01T00:00:00.000Z',
'2024-05-01T00:00:00.000Z',
'2024-05-09T00:00:00.000Z',
'2024-05-20T00:00:00.000Z',
'2024-10-03T00:00:00.000Z',
'2024-10-31T00:00:00.000Z',
'2024-12-25T00:00:00.000Z',
'2024-12-26T00:00:00.000Z',
'2025-01-01T00:00:00.000Z',
'2025-04-18T00:00:00.000Z',
'2025-04-21T00:00:00.000Z',
'2025-05-01T00:00:00.000Z',
'2025-05-29T00:00:00.000Z',
'2025-06-09T00:00:00.000Z',
'2025-10-03T00:00:00.000Z',
'2025-10-31T00:00:00.000Z',
'2025-12-25T00:00:00.000Z',
'2025-12-26T00:00:00.000Z',
'2026-01-01T00:00:00.000Z',
'2026-04-03T00:00:00.000Z',
'2026-04-06T00:00:00.000Z',
'2026-05-01T00:00:00.000Z',
'2026-05-14T00:00:00.000Z',
'2026-05-25T00:00:00.000Z',
'2026-10-03T00:00:00.000Z',
'2026-10-31T00:00:00.000Z',
'2026-12-25T00:00:00.000Z',
'2026-12-26T00:00:00.000Z',
'2027-01-01T00:00:00.000Z',
'2027-03-26T00:00:00.000Z',
'2027-03-29T00:00:00.000Z',
'2027-05-01T00:00:00.000Z',
'2027-05-06T00:00:00.000Z',
'2027-05-17T00:00:00.000Z',
'2027-10-03T00:00:00.000Z',
'2027-10-31T00:00:00.000Z',
'2027-12-25T00:00:00.000Z',
'2027-12-26T00:00:00.000Z',
'2028-01-01T00:00:00.000Z',
'2028-04-14T00:00:00.000Z',
'2028-04-17T00:00:00.000Z',
'2028-05-01T00:00:00.000Z',
'2028-05-25T00:00:00.000Z',
'2028-06-05T00:00:00.000Z',
'2028-10-03T00:00:00.000Z',
'2028-10-31T00:00:00.000Z',
'2028-12-25T00:00:00.000Z',
'2028-12-26T00:00:00.000Z',
'2029-01-01T00:00:00.000Z',
'2029-03-30T00:00:00.000Z',
'2029-04-02T00:00:00.000Z',
'2029-05-01T00:00:00.000Z',
'2029-05-10T00:00:00.000Z',
'2029-05-21T00:00:00.000Z',
'2029-10-03T00:00:00.000Z',
'2029-10-31T00:00:00.000Z',
'2029-12-25T00:00:00.000Z',
'2029-12-26T00:00:00.000Z',
'2030-01-01T00:00:00.000Z',
'2030-04-19T00:00:00.000Z',
'2030-04-22T00:00:00.000Z',
'2030-05-01T00:00:00.000Z',
'2030-05-30T00:00:00.000Z',
'2030-06-10T00:00:00.000Z',
'2030-10-03T00:00:00.000Z',
'2030-10-31T00:00:00.000Z',
'2030-12-25T00:00:00.000Z',
'2030-12-26T00:00:00.000Z',
];
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { CalculationService } from './calculation.service';
import { PersonnelCostCalculationService } from './personnel-cost-calculation.service';
@Module({
imports: [PrismaModule],
providers: [CalculationService, PersonnelCostCalculationService],
exports: [CalculationService],
})
export class UtilCalculationModule {}
@@ -75,7 +75,8 @@ export class ImporterService {
private runTransformers(jobId: string): Observable<void> {
return combineLatest([
this.transformersService.transformAttendanceRegistrations(jobId),
this.transformersService.transformEmployees(jobId),
this.transformersService.transformDyflexisEmployees(jobId),
this.transformersService.transformDispoliveEmployees(jobId),
this.transformersService.transformPatients(jobId),
this.transformersService.transformPlannedTimes(jobId),
this.transformersService.transformWorkTimes(jobId),
@@ -96,6 +97,8 @@ export class ImporterService {
private runLoaders(jobId: string): Observable<void> {
return this.loadersService.loadTours().pipe(
switchMap(() => this.loadersService.loadPatients()),
switchMap(() => this.loadersService.loadDyflexisEmployees()),
switchMap(() => this.loadersService.loadDispoliveEmployees()),
switchMap(() => this.loadersService.loadAttendanceRegistrations()),
switchMap(() => this.loadersService.loadPlannedTimes()),
switchMap(() => this.loadersService.loadWorkTimes()),
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import {
AttendanceRegistration,
DyflexisEmployee,
Employee,
Patient,
PlannedTime,
Prisma,
@@ -115,6 +117,76 @@ export class FinalRepository {
});
}
findEmployeeDyflexis(): Promise<DyflexisEmployee[]> {
return this.prisma.dyflexisEmployee.findMany({
orderBy: {
surname: 'asc',
},
});
}
createEmployeeDyflexis(
employeeDyflexisCreateInput: Prisma.DyflexisEmployeeCreateInput[],
): Promise<Prisma.BatchPayload> {
return this.prisma.dyflexisEmployee.createMany({
data: employeeDyflexisCreateInput,
skipDuplicates: true,
});
}
async updateEmployeeDyflexis(
employeeDyflexisCreateInput: Prisma.DyflexisEmployeeCreateInput[],
): Promise<Prisma.BatchPayload> {
await this.deleteEmployeeDyflexis(
employeeDyflexisCreateInput.map(({ id }) => id),
);
return this.createEmployeeDyflexis(employeeDyflexisCreateInput);
}
deleteEmployeeDyflexis(ids: number[]): Promise<Prisma.BatchPayload> {
return this.prisma.dyflexisEmployee.deleteMany({
where: {
id: {
in: ids,
},
},
});
}
findEmployeeDispolive(): Promise<Employee[]> {
return this.prisma.employee.findMany({
orderBy: {
surname: 'asc',
},
});
}
createEmployeeDispolive(
employeeCreateInput: Prisma.EmployeeCreateInput[],
): Promise<Prisma.BatchPayload> {
return this.prisma.employee.createMany({
data: employeeCreateInput,
skipDuplicates: true,
});
}
async updateEmployeeDispolive(
employeeCreateInput: Prisma.EmployeeCreateInput[],
): Promise<Prisma.BatchPayload> {
await this.deleteEmployeeDispolive(employeeCreateInput.map(({ id }) => id));
return this.createEmployeeDispolive(employeeCreateInput);
}
deleteEmployeeDispolive(ids: string[]): Promise<Prisma.BatchPayload> {
return this.prisma.employee.deleteMany({
where: {
id: {
in: ids,
},
},
});
}
findAttendanceRegistrationsInInterval(
interval: Interval,
): Promise<AttendanceRegistration[]> {
@@ -4,6 +4,8 @@ import { from, map, of, switchMap } from 'rxjs';
import { TransformersService } from '../feat-transformers/transformers.service';
import { FinalRepository } from './final.repository';
import { AttendanceRegistrationLoader } from './loaders/attendance-registration.loader';
import { EmployeeDispoliveLoader } from './loaders/employee-dispolive';
import { EmployeeDyflexisLoader } from './loaders/employee-dyflexis';
import { PatientLoader } from './loaders/patient.loader';
import { PlannedTimeLoader } from './loaders/planned-time.loader';
import { TourLoader } from './loaders/tour.loader';
@@ -82,6 +84,56 @@ export class LoadersService {
);
}
loadDyflexisEmployees() {
return from(this.transformersService.findDyflexisEmployees()).pipe(
switchMap((stagingEmployees) =>
from(this.repository.findEmployeeDyflexis()).pipe(
map((finalEmployees) => ({
final: finalEmployees,
staging: stagingEmployees,
})),
),
),
map(({ staging, final }) =>
EmployeeDyflexisLoader.determineLoadOperations(staging, final),
),
switchMap(({ deleted, updated, created }) =>
from(
Promise.all([
this.repository.deleteEmployeeDyflexis(deleted),
this.repository.updateEmployeeDyflexis(updated),
this.repository.createEmployeeDyflexis(created),
]),
),
),
);
}
loadDispoliveEmployees() {
return from(this.transformersService.findDispoliveEmployees()).pipe(
switchMap((stagingEmployees) =>
from(this.repository.findEmployeeDispolive()).pipe(
map((finalEmployees) => ({
final: finalEmployees,
staging: stagingEmployees,
})),
),
),
map(({ staging, final }) =>
EmployeeDispoliveLoader.determineLoadOperations(staging, final),
),
switchMap(({ deleted, updated, created }) =>
from(
Promise.all([
this.repository.deleteEmployeeDispolive(deleted),
this.repository.updateEmployeeDispolive(updated),
this.repository.createEmployeeDispolive(created),
]),
),
),
);
}
loadAttendanceRegistrations() {
return from(this.transformersService.findAttendanceRegistrations()).pipe(
switchMap((stagingAttendanceRegistrations) => {
@@ -0,0 +1,65 @@
import { Employee, EmployeeDispoLiveStaging, Prisma } from '@prisma/client';
export class EmployeeDispoliveLoader {
public static determineLoadOperations(
stagingEmployees: EmployeeDispoLiveStaging[],
finalEmployees: Employee[],
): {
deleted: string[];
updated: Prisma.EmployeeCreateInput[];
created: Prisma.EmployeeCreateInput[];
} {
const deletedEmployees = finalEmployees.filter(
(finalEmployee) =>
!stagingEmployees.find(
(stagingEmployee) => stagingEmployee.id === finalEmployee.id,
),
);
const employeesToInsert = stagingEmployees.filter(
({ id }) =>
!!id &&
!deletedEmployees.find((deletedEmployee) => deletedEmployee.id === id),
);
const newEmployees: Prisma.EmployeeCreateInput[] = [];
const updatedEmployees: Prisma.EmployeeCreateInput[] = [];
for (const stagingEmployee of employeesToInsert) {
const finalEmployee = finalEmployees.find(
(finalEmployee) => finalEmployee.id === stagingEmployee.id,
);
if (finalEmployee) {
updatedEmployees.push(this.employeeStagingToFinal(stagingEmployee));
} else {
newEmployees.push(this.employeeStagingToFinal(stagingEmployee));
}
}
return {
deleted: deletedEmployees.map(({ id }) => id),
updated: updatedEmployees,
created: newEmployees,
};
}
private static employeeStagingToFinal(
employee: EmployeeDispoLiveStaging,
): Prisma.EmployeeCreateInput {
const {
id,
name,
surname,
personnelNumber,
qualification,
personTransportCertificate,
} = employee;
return {
id,
name,
surname,
personnelNumber,
qualification,
personTransportCertificate,
};
}
}
@@ -0,0 +1,71 @@
import {
DyflexisEmployee,
EmployeeDyflexisStaging,
Prisma,
} from '@prisma/client';
export class EmployeeDyflexisLoader {
public static determineLoadOperations(
stagingEmployees: EmployeeDyflexisStaging[],
finalEmployees: DyflexisEmployee[],
): {
deleted: number[];
updated: Prisma.DyflexisEmployeeCreateInput[];
created: Prisma.DyflexisEmployeeCreateInput[];
} {
const deletedEmployees = finalEmployees.filter(
(finalEmployee) =>
!stagingEmployees.find(
(stagingEmployee) => stagingEmployee.id === finalEmployee.id,
),
);
const employeesToInsert = stagingEmployees.filter(
({ id }) =>
!!id &&
!deletedEmployees.find((deletedEmployee) => deletedEmployee.id === id),
);
const newEmployees: Prisma.DyflexisEmployeeCreateInput[] = [];
const updatedEmployees: Prisma.DyflexisEmployeeCreateInput[] = [];
for (const stagingEmployee of employeesToInsert) {
const finalEmployee = finalEmployees.find(
(finalEmployee) => finalEmployee.id === stagingEmployee.id,
);
if (finalEmployee) {
updatedEmployees.push(this.employeeStagingToFinal(stagingEmployee));
} else {
newEmployees.push(this.employeeStagingToFinal(stagingEmployee));
}
}
return {
deleted: deletedEmployees.map(({ id }) => id),
updated: updatedEmployees,
created: newEmployees,
};
}
private static employeeStagingToFinal(
employee: EmployeeDyflexisStaging,
): Prisma.DyflexisEmployeeCreateInput {
const {
id,
firstname,
surname,
contractStart,
contractEnd,
contractHoursWeek,
contractSalaryHour,
} = employee;
return {
id,
firstname,
surname,
contractStart,
contractEnd,
contractHoursWeek,
contractSalaryHour,
};
}
}
@@ -0,0 +1,20 @@
import { ApprovalState } from '@prisma/client';
import { TicketFilter } from './ticket-filter';
export class TicketFilterArgs implements TicketFilter {
errors: string[];
approvalStates: ApprovalState[];
documentInfo: ('approval' | 'transport_document')[];
}
export interface ApprovalInfoArgs {
isNeeded?: boolean;
whoRequested?: string;
answeredFrom?: string;
location?: string;
}
export interface DocumentInfoArgs {
approval: ApprovalInfoArgs;
tdLocation?: string;
}
@@ -0,0 +1,71 @@
import { ApprovalState, Prisma } from '@prisma/client';
export interface TicketFilter {
errors?: string[];
approvalStates?: ApprovalState[];
documentInfo?: ('approval' | 'transport_document')[];
}
export function createPrismaQueryFromTicketFilters(
filter: TicketFilter,
): Prisma.TicketWhereInput[] {
const conditions: Prisma.TicketWhereInput[] = [];
Object.entries(filter ?? {}).forEach(([property, args]) => {
switch (property) {
case 'errors':
if (args.length > 0)
conditions.push({
errors: {
hasSome: [...args],
},
});
return;
case 'approvalStates':
if (args.length > 0)
conditions.push({
approvalState: {
in: [...args],
},
});
return;
case 'documentInfo':
if (args.includes('approval')) {
conditions.push({
OR: [
{
documentInfo: {
path: ['approval', 'location'],
not: 'company',
},
},
{
documentInfo: {
path: ['approval', 'location'],
equals: Prisma.JsonNull,
},
},
],
});
}
if (args.includes('td_company')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'company',
},
});
}
if (args.includes('td_customer')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'customer',
},
});
}
return;
}
});
return conditions;
}
@@ -0,0 +1,240 @@
import { Injectable, Logger } from '@nestjs/common';
import {
ApprovalState,
Ticket,
TicketValidationState,
Tour,
} from '@prisma/client';
import {
ValidationResult,
validatePatient,
validatePatientDataOnTours,
validateTour,
} from './ticket-validation.functions';
import { TicketRepository } from './ticket.repository';
import { DocumentInfoArgs } from './ticket-arg.types';
import { Interval } from 'date-fns';
@Injectable()
export class TicketLifecycleService {
private readonly logger = new Logger(TicketLifecycleService.name);
constructor(private readonly repository: TicketRepository) {}
async createTickets(interval: Interval): Promise<void> {
const tours = await this.repository.findTours(interval);
for (const tour of tours) {
if (tour.ticketId) {
continue;
}
const ticketId = await this.repository.searchTicketIDForTour(tour);
if (ticketId) {
await this.repository.addToursToTicket(ticketId, [tour]);
} else {
await this.repository.createTicket([tour]);
}
}
await this.validateCreatedTickets();
// await this.archiveClosedTickets();
}
async validateDocumentInfoUpdate(
ticketId: string,
documentInfo: DocumentInfoArgs,
) {
const ticket = await this.repository.findById(ticketId);
switch (ticket.currentState) {
case TicketValidationState.USER_INTERACTION_REQUIRED:
const approvalStateUpdate = documentInfo.approval.isNeeded
? ApprovalState.REQUESTED
: ApprovalState.FREE;
let stateUpdate: TicketValidationState =
TicketValidationState.USER_INTERACTION_REQUIRED;
if (
documentInfo.tdLocation === 'company' &&
approvalStateUpdate === ApprovalState.FREE
)
stateUpdate = TicketValidationState.ARCHIVED;
else if (documentInfo.tdLocation === 'uncertain')
stateUpdate = TicketValidationState.TD_UNCERTAIN;
else stateUpdate = TicketValidationState.DOCUMENTS_MISSING;
return this.repository.updateTicket(ticketId, {
documentInfo,
approvalState: approvalStateUpdate,
state: stateUpdate,
});
case TicketValidationState.TD_UNCERTAIN:
return this.repository.updateTicket(ticketId, {
documentInfo,
state:
ticket.approvalState === ApprovalState.FREE &&
documentInfo.tdLocation === 'company'
? TicketValidationState.ARCHIVED
: TicketValidationState.DOCUMENTS_MISSING,
});
case TicketValidationState.DOCUMENTS_MISSING:
return this.repository.updateTicket(ticketId, {
documentInfo,
...((ticket.approvalState === ApprovalState.FREE ||
documentInfo.approval.location === 'company') &&
documentInfo.tdLocation === 'company'
? { state: TicketValidationState.ARCHIVED }
: {}),
});
}
}
private async validateCreatedTickets(): Promise<void> {
const tickets = await this.repository.findAllCreated();
this.logger.debug(`Found ${tickets.length} with state CREATED`);
for (const ticket of tickets) {
const {
result: isValid,
errors,
notes,
} = await this.validateCreatedTicket(ticket);
if (isValid) {
const { result: approvalResult } = await this.isApprovalNeeded(ticket);
if (approvalResult) {
await this.repository.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.USER_INTERACTION_REQUIRED,
approvalState: ticket.tours.every(({ patientId }) => !patientId)
? ApprovalState.UNKNOWN
: ApprovalState.REQUIRED,
});
} else {
await this.repository.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.USER_INTERACTION_REQUIRED,
approvalState: ApprovalState.FREE,
});
}
} else {
await this.repository.setActionablesOnTicket(ticket.id, errors, notes);
}
}
}
private async archiveClosedTickets() {
const closedTickets = await this.repository.findAllClosed();
for (const closedTicket of closedTickets) {
await this.repository.setStateOfTicket(
closedTicket.id,
TicketValidationState.ARCHIVED,
);
}
}
private async validateCreatedTicket(
ticket: Ticket & { tours: Tour[] },
): Promise<ValidationResult> {
const patientIds = [
...new Set(ticket.tours.map(({ patientId }) => patientId)),
];
if (patientIds.length > 1) {
return {
result: false,
errors: ['Ticket: Die Touren umfassen mehr als einen Patienten'],
notes: [],
};
}
const [patientId] = patientIds;
if (!patientId && ticket.tours.at(0)?.type !== 'Spontan') {
return {
result: false,
errors: [],
notes: ['Patient: Der Patient ist nicht in DispoLive hinterlegt'],
};
}
let patientResult: ValidationResult;
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
patientResult = validatePatientDataOnTours(ticket.tours);
} else {
try {
const patient = await this.repository.findPatientById(patientId);
patientResult = validatePatient(patient);
} catch {
patientResult = validatePatientDataOnTours(ticket.tours);
}
}
const tourResults = ticket.tours.map((tour) => validateTour(tour));
return [patientResult, ...tourResults].reduce(
(result, results) => ({
result: result.result && results.result,
errors: [...result.errors, ...results.errors],
notes: [...result.notes, ...results.notes],
}),
{ result: true, errors: [], notes: [] } as ValidationResult,
);
}
private async isApprovalNeeded(
ticket: Ticket & { tours: Tour[] },
): Promise<ValidationResult> {
const approvalRequirements = await Promise.all(
ticket.tours.map((tour) => this.checkTourApprovalRequirement(tour)),
);
const isApprovalRequired = approvalRequirements.reduce(
(result, requirement) => result || requirement,
false,
);
return {
result: isApprovalRequired,
errors: [],
notes:
isApprovalRequired && ticket.tours.every(({ patientId }) => !patientId)
? [
'Patient nicht in Stammdaten: Prüfen Sie ob eine Genehmigung benötigt wird',
]
: [],
};
}
private async checkTourApprovalRequirement(tour: Tour): Promise<boolean> {
if (
[
'Einweisung',
'Entlassung',
'Entlassung Vorbestellung',
'Konsil KH - Dia',
'Privatfahrt Rechnung',
'Tagesklinik',
'Verlegung',
].includes(tour.category) ||
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
) {
return false;
}
if (
tour.patientId &&
['BTW', 'TSW'].includes(tour.ordinanceType) &&
tour.category !== 'Dialyse'
) {
const patient = await this.repository.findPatientById(tour.patientId);
if (
['3', '4', '5'].includes(patient.careDegree) &&
(['aG', 'Bi', 'H'].includes(patient.disabilityMark1) ||
['aG', 'Bi', 'H'].includes(patient.disabilityMark2))
) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,7 @@
export enum TicketSystemEventTypes {
Validated = 'ticket-system.validated',
}
export class TicketSystemValidatedEvent {
constructor(public readonly count: number) {}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { TicketLifecycleService } from './ticket-lifecycle.service';
import { TicketRepository } from './ticket.repository';
import { TicketService } from './tickets.service';
@Module({
imports: [PrismaModule],
providers: [TicketRepository, TicketService, TicketLifecycleService],
exports: [TicketService],
})
export class TicketSystemModule {}
@@ -0,0 +1,153 @@
import { Patient, Tour } from '@prisma/client';
export interface ValidationResult {
result: boolean;
notes: string[];
errors: string[];
}
export function validatePatient(patient: Patient): ValidationResult {
const notes: string[] = [];
const errors: string[] = [];
let isValid = true;
if (!patient?.name) {
isValid = false;
errors.push('Patient: Vorname ist nicht gefüllt');
}
if (!patient?.surname) {
isValid = false;
errors.push('Patient: Nachname ist nicht gefüllt');
}
if (!patient?.birthday) {
isValid = false;
notes.push('Patient: Geburtstag ist nicht gefüllt');
}
if (!patient?.healthinsurance) {
isValid = false;
errors.push('Patient: Krankenkasse ist nicht gefüllt');
}
if (!patient?.careDegree) {
notes.push('Patient: Pflegegrad ist nicht gefüllt');
}
if (!patient?.disabilityMark1) {
notes.push('Patient: Merkzeichen 1 ist nicht gefüllt');
}
if (!patient?.disabilityMark2) {
notes.push('Patient: Merkzeichen 2 ist nicht gefüllt');
}
return {
result: isValid,
errors,
notes,
};
}
export function validatePatientDataOnTours(tours: Tour[]): ValidationResult {
const errors: string[] = [];
let isValid = true;
for (const tour of tours) {
if (!tour.patientName) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Vorname ist nicht gefüllt`,
);
}
if (!tour.patientSurname) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Nachname ist nicht gefüllt`,
);
}
if (!tour.healthInsurance) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Krankenversicherung ist nicht gefüllt`,
);
}
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTour(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.ordinanceType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Verordnungsart ist nicht gefüllt`);
}
if (!tour.transportType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Transportart ist nicht gefüllt`);
}
if (!tour.category) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Kategorie ist nicht gefüllt`);
}
if (!tour.startDate) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Startzeit ist nicht gefüllt`);
}
if (!tour.startStreet || !tour.startZip || !tour.startCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Start Adresse ist nicht gefüllt`);
}
if (!tour.targetStreet || !tour.targetZip || !tour.targetCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Ziel Adresse ist nicht gefüllt`);
}
if (tour.occupiedKm > 9990) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Besetzte Kilometer sind zu hoch`);
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTransport(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.carName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrzeugname ist nicht angegeben`);
}
if (!tour.driverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrer ist nicht angegeben`);
}
if (!tour.codriverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Beifahrer ist nicht angegeben`);
}
if (!tour.startBegin) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Anfahrt-Stempel fehlt`);
}
if (!tour.empty) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Frei-Stempel fehlt`);
}
return {
result: isValid,
errors,
notes: [],
};
}
@@ -0,0 +1,499 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Patient, Prisma, TicketValidationState, Tour } from '@prisma/client';
import { Interval, endOfDay, startOfDay } from 'date-fns';
import { ExpressionBuilder, Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service';
import { ApprovalState, DB } from 'src/core/database/types';
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
import { createPrismaQueryFromTicketFilters } from './ticket-filter';
function withTours(eb: ExpressionBuilder<DB, 'Ticket'>) {
return jsonArrayFrom(
eb
.selectFrom('Tour')
.selectAll('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.orderBy('Tour.operationId'),
).as('tours');
}
@Injectable()
export class TicketRepository {
private logger = new Logger(TicketRepository.name);
constructor(
private readonly prisma: PrismaService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async createTicket(tours: Tour[]) {
const ticket = await this.db
.insertInto('Ticket')
.values({
currentState: TicketValidationState.CREATED,
})
.returningAll()
.executeTakeFirst();
await this.db
.insertInto('TicketStateHistory')
.values({
state: TicketValidationState.CREATED,
ticketId: ticket.id,
})
.executeTakeFirst();
await this.db
.updateTable('Tour')
.set({
ticketId: ticket.id,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
return ticket;
}
async addToursToTicket(ticketId: string, tours: Tour[]) {
await this.db
.updateTable('Tour')
.set({
ticketId: ticketId,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
return this.db
.selectFrom('Ticket')
.where('id', '=', ticketId)
.selectAll()
.executeTakeFirst();
}
setActionablesOnTicket(ticketId: string, errors: string[], notes: string[]) {
return this.db
.updateTable('Ticket')
.set({ errors, notes })
.where('id', '=', ticketId)
.returningAll()
.executeTakeFirst();
}
async setStateOfTicket(ticketId: string, state: TicketValidationState) {
await this.db
.updateTable('Ticket')
.set({
currentState: state,
})
.where('Ticket.id', '=', ticketId)
.execute();
return this.db
.insertInto('TicketStateHistory')
.values({ state, ticketId })
.returningAll()
.executeTakeFirst();
}
async updateTicket(
ticketId: string,
data: {
state?: TicketValidationState;
errors?: string[];
notes?: string[];
isUrgent?: boolean;
urgency?: Date;
approvalState?: ApprovalState;
documentInfo?: DocumentInfoArgs;
approvalPath?: string;
},
) {
const { state, ...ticketData } = data;
await this.db
.updateTable('Ticket')
.set({ ...ticketData })
.where('id', '=', ticketId)
.execute();
if (state) {
await this.setStateOfTicket(ticketId, state);
}
return this.db
.selectFrom('Ticket')
.where('id', '=', ticketId)
.selectAll('Ticket')
.select((eb) => withTours(eb))
.executeTakeFirst();
}
findById(id: string) {
return this.prisma.ticket.findUnique({
where: {
id,
},
include: {
tours: true,
},
});
}
findAll() {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.ARCHIVED,
},
},
},
include: {
tours: true,
},
});
}
findAllCreated() {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.USER_INTERACTION_REQUIRED,
},
},
},
include: {
tours: true,
},
});
}
findAllClosed() {
return this.prisma.ticket.findMany({
where: {
tours: {
every: {
check: {
not: 0,
},
},
},
},
});
}
search(query: string) {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.ARCHIVED,
},
},
tours: {
some: {
OR: [
...query.split(' ').flatMap((word) => [
{
patientName: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
{
patientSurname: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
{
operationId: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
]),
],
},
},
},
take: 5,
include: {
tours: true,
},
});
}
async findByState(
state: TicketValidationState,
filter: TicketFilterArgs,
take?: number,
skip?: number,
) {
const query: Prisma.TicketFindManyArgs = {
...{
...(skip ? { skip, take: 10 } : {}),
...(take ? { take } : {}),
...(take && !skip ? { skip: 0 } : {}),
},
where: {
AND: createPrismaQueryFromTicketFilters(filter),
currentState: state,
tours: {
some: {
check: 0,
},
},
},
orderBy: { createdAt: 'desc' },
};
const [tickets, count] = await this.prisma.$transaction([
this.prisma.ticket.findMany({ ...query }),
this.prisma.ticket.count({ where: query.where }),
]);
return {
tickets,
total: count,
};
}
findPatientById(patientId: string): Promise<Patient> {
return this.prisma.patient.findUnique({ where: { id: patientId } });
}
findStatesByTicketId(
ticketId: string,
): Promise<{ state: TicketValidationState }[]> {
return this.prisma.ticketStateHistory.findMany({
where: {
ticketId,
},
select: {
state: true,
},
});
}
findToursByTicketId(ticketId: string): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
ticketId,
},
});
}
async searchTicketIDForTour(tour: Tour): Promise<string | undefined> {
const tours = await Promise.all([
this.findFirstRecurringTour(tour),
this.findTourOnSameDayWithOppositeDirection(tour),
]);
const firstFoundTour = tours.filter((tour) => !!tour)[0];
if (firstFoundTour) {
const ticket = await this.prisma.ticket.findFirst({
select: {
id: true,
},
where: {
tours: {
some: {
id: firstFoundTour.id,
},
},
},
});
return ticket?.id;
}
return undefined;
}
findFirstRecurringTour(tour: Tour): Promise<Tour | null> {
const institutionStreet =
tour.direction === 'Hinfahrt' ? tour.targetStreet : tour.startStreet;
return this.prisma.tour.findFirst({
where: {
type: 'Serie',
category: tour.category,
ordinanceType: tour.ordinanceType,
patientId: tour.patientId,
patientName: tour.patientName,
patientSurname: tour.patientSurname,
patientStreet: tour.patientStreet,
patientCity: tour.patientCity,
patientZip: tour.patientZip,
OR: [
{ targetStreet: institutionStreet },
{ startStreet: institutionStreet },
],
rangeEndDate: {
gte: tour.startDate,
},
ticketId: {
not: null,
},
},
orderBy: {
startDate: 'desc',
},
});
}
findTourOnSameDayWithOppositeDirection(tour: Tour): Promise<Tour | null> {
return this.prisma.tour.findFirst({
where: {
AND: [
{
startDate: {
gt: startOfDay(new Date(tour.startDate)),
},
},
{
startDate: {
lt: endOfDay(new Date(tour.startDate)),
},
},
{
startStreet: tour.targetStreet,
},
{
targetStreet: tour.startStreet,
},
{
direction: tour.direction === 'Hinfahrt' ? 'Rückfahrt' : 'Hinfahrt',
},
],
},
});
}
findToursWithTicket(interval: Interval): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
ticketId: {
not: null,
},
},
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
],
},
});
}
removeTourFromTicket(tourId: string): Promise<Tour> {
return this.prisma.tour.update({
where: {
id: tourId,
},
data: {
Ticket: {
disconnect: true,
},
},
});
}
findTours(interval: Interval): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
check: {
not: 3,
},
},
{
deletedAt: null,
},
{
ticketId: null,
},
{
AND: [
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
async deleteEmptyTickets(): Promise<Prisma.BatchPayload> {
return this.prisma.ticket.deleteMany({
where: {
tours: {
none: {},
},
},
});
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
import { Interval } from 'date-fns';
import { DocumentInfoArgs } from './ticket-arg.types';
import { TicketLifecycleService } from './ticket-lifecycle.service';
import {
TicketSystemEventTypes,
TicketSystemValidatedEvent,
} from './ticket-system.events';
import { TicketRepository } from './ticket.repository';
@Injectable()
export class TicketService {
constructor(
private readonly repository: TicketRepository,
private readonly lifecycle: TicketLifecycleService,
private readonly eventEmitter: EventEmitter2,
) {}
async createTickets(interval: Interval): Promise<void> {
await this.lifecycle.createTickets(interval);
this.eventEmitter.emit(
TicketSystemEventTypes.Validated,
new TicketSystemValidatedEvent(0),
);
}
async updateDocumentInfo(ticketId: string, documentInfo: DocumentInfoArgs) {
return this.lifecycle.validateDocumentInfoUpdate(ticketId, documentInfo);
}
findById(id: string): Promise<Ticket> {
return this.repository.findById(id);
}
findAll(): Promise<Ticket[]> {
return this.repository.findAll() as Promise<Ticket[]>;
}
searchTickets(query: string): Promise<Ticket[]> {
return this.repository.search(query);
}
findToursByTicketId(ticketId: string): Promise<Tour[]> {
return this.repository.findToursByTicketId(ticketId);
}
findStatesByTicketId(ticketId: string): Promise<TicketValidationState[]> {
return this.repository
.findStatesByTicketId(ticketId)
.then((states) => states.map(({ state }) => state));
}
}
@@ -1,54 +1,55 @@
// import { CalculationService } from '@dks/avicenna/api-util-calculation';
// import { Injectable } from '@nestjs/common';
// import { Tour } from '@prisma/client';
// import { chunk } from 'src/utils';
// import { TourPreStaging } from '../staging.interface';
import { Injectable } from '@nestjs/common';
import { Tour } from '@prisma/client';
// @Injectable()
// export class ToursCalculationAdapater {
// constructor(private readonly calculationService: CalculationService) {}
import { CalculationService } from 'src/modules/feat-calculation/calculation.service';
import { chunk } from 'src/utils';
import { TourPreStaging } from '../staging.interface';
// public async addRevenueAndConsumptionCostsToTours(
// tours: TourPreStaging[],
// ): Promise<TourPreStaging[]> {
// const resultTours = [];
// for (const tourChunk of chunk(tours, 5)) {
// resultTours.push(
// await Promise.all(
// tourChunk.map((tour) =>
// this.addRevenueAndConsumptionCostsToTour(tour),
// ),
// ),
// );
// }
// return resultTours.flat();
// }
@Injectable()
export class ToursCalculationAdapater {
constructor(private readonly calculationService: CalculationService) {}
// public async addRevenueAndConsumptionCostsToTour(
// tour: TourPreStaging,
// ): Promise<TourPreStaging> {
// if (!tour?.ordinanceType) {
// return tour;
// }
// const revenue = await this.calculationService.calculateTourRevenue(
// tour as unknown as Tour,
// );
// const revenueDeviation =
// tour.revenueDispoLive > 0
// ? Math.floor((revenue - tour.revenueDispoLive) * 100) / 100
// : 0;
public async addRevenueAndConsumptionCostsToTours(
tours: TourPreStaging[],
): Promise<TourPreStaging[]> {
const resultTours = [];
for (const tourChunk of chunk(tours, 5)) {
resultTours.push(
await Promise.all(
tourChunk.map((tour) =>
this.addRevenueAndConsumptionCostsToTour(tour),
),
),
);
}
return resultTours.flat();
}
// const { consumptionCostsPerTour } =
// this.calculationService.getBillingInformationByOrdinanceType(
// tour.ordinanceType.match(/([A-Z])\w+/).at(0),
// tour.startDate,
// );
public async addRevenueAndConsumptionCostsToTour(
tour: TourPreStaging,
): Promise<TourPreStaging> {
if (!tour?.ordinanceType) {
return tour;
}
const revenue = await this.calculationService.calculateTourRevenue(
tour as unknown as Tour,
);
const revenueDeviation =
tour.revenueDispoLive > 0
? Math.floor((revenue - tour.revenueDispoLive) * 100) / 100
: 0;
// return {
// ...tour,
// revenue,
// revenueDeviation,
// consumptionCosts: consumptionCostsPerTour,
// };
// }
// }
const { consumptionCostsPerTour } =
this.calculationService.getBillingInformationByOrdinanceType(
tour.ordinanceType.match(/([A-Z])\w+/).at(0),
tour.startDate,
);
return {
...tour,
revenue,
revenueDeviation,
consumptionCosts: consumptionCostsPerTour,
};
}
}
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
// import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
import { PrismaModule } from 'src/core/database/prisma.module';
import { UtilCalculationModule } from '../feat-calculation/util-calculation.module';
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
import { StagingRepository } from './staging.repository';
import { TransformersService } from './transformers.service';
import { PrismaModule } from 'src/core/database/prisma.module';
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
@Module({
imports: [PrismaModule, DataAccessExtractorsModule],
providers: [TransformersService, StagingRepository],
imports: [PrismaModule, DataAccessExtractorsModule, UtilCalculationModule],
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
exports: [TransformersService],
})
export class DataAccessTransformersModule {}
@@ -104,7 +104,7 @@ export interface WorkTimePreStaging {
endDate: Date;
}
export interface EmployeePreStaging {
export interface EmployeeDyflexisPreStaging {
id: number;
firstname: string;
surname: string;
@@ -112,6 +112,13 @@ export interface EmployeePreStaging {
contractEnd: Date;
contractHoursWeek: number;
contractSalaryHour: number;
personTransportCertificate?: Date;
qualification?: string;
}
export interface EmployeeDispoLivePreStaging {
id: string;
name: string;
surname: string;
personnelNumber?: string;
qualification?: string;
personTransportCertificate?: Date;
}
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import {
AttendanceRegistrationStaging,
EmployeeDispoLiveStaging,
EmployeeDyflexisStaging,
PatientStaging,
PlannedTimeStaging,
Prisma,
@@ -10,7 +12,8 @@ import {
import { PrismaService } from 'src/core/database/prisma.service';
import {
AttendanceRegistrationPreStaging,
EmployeePreStaging,
EmployeeDispoLivePreStaging,
EmployeeDyflexisPreStaging,
PatientPreStaging,
PlannedTimePreStaging,
TourPreStaging,
@@ -24,7 +27,8 @@ export class StagingRepository {
clearStaging(): Promise<Prisma.BatchPayload[]> {
return Promise.all([
this.prisma.tourStaging.deleteMany(),
this.prisma.employeeStaging.deleteMany(),
this.prisma.employeeDispoLiveStaging.deleteMany(),
this.prisma.employeeDyflexisStaging.deleteMany(),
this.prisma.workTimeStaging.deleteMany(),
this.prisma.plannedTimeStaging.deleteMany(),
this.prisma.attendanceRegistrationStaging.deleteMany(),
@@ -68,14 +72,38 @@ export class StagingRepository {
});
}
createEmployees(
employees: EmployeePreStaging[],
createDyflexisEmployees(
employees: EmployeeDyflexisPreStaging[],
): Promise<Prisma.BatchPayload> {
return this.prisma.employeeStaging.createMany({
return this.prisma.employeeDyflexisStaging.createMany({
data: employees,
});
}
createDispoliveEmployees(
employees: EmployeeDispoLivePreStaging[],
): Promise<Prisma.BatchPayload> {
return this.prisma.employeeDispoLiveStaging.createMany({
data: employees,
});
}
findDyflexisEmployees(): Promise<EmployeeDyflexisStaging[]> {
return this.prisma.employeeDyflexisStaging.findMany({
orderBy: {
surname: 'asc',
},
});
}
findDispoliveEmployees(): Promise<EmployeeDispoLiveStaging[]> {
return this.prisma.employeeDispoLiveStaging.findMany({
orderBy: {
surname: 'asc',
},
});
}
findPatients(): Promise<PatientStaging[]> {
return this.prisma.patientStaging.findMany({
orderBy: {
@@ -2,20 +2,23 @@ import { Injectable } from '@nestjs/common';
import {
AttendanceRegistrationStaging,
EmployeeDispoLiveSource,
EmployeeDispoLiveStaging,
EmployeeDyflexisSource,
EmployeeDyflexisStaging,
PatientStaging,
PlannedTimeStaging,
Prisma,
TourStaging,
WorkTimeStaging,
} from '@prisma/client';
import { combineLatest, from, map, switchMap } from 'rxjs';
import { from, map, switchMap } from 'rxjs';
import { ExtractorsService } from '../feat-extractors/extractors.service';
// import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
import { StagingRepository } from './staging.repository';
import { AttendanceRegistrationTransformer } from './transformers/attendance-registration.transformer';
import { EmployeeTransformer } from './transformers/employee.transformer';
import { DispoliveEmployeeTransformer } from './transformers/employee-dispolive.transformer';
import { DyflexisEmployeeTransformer } from './transformers/employee-dyflexis.transformer';
import { PatientTransformer } from './transformers/patient.transformer';
import { PlannedTimeTransformer } from './transformers/planned-time.transformer';
import { TourTransformer } from './transformers/tour.transformer';
@@ -26,7 +29,7 @@ export class TransformersService {
constructor(
private readonly extractorsService: ExtractorsService,
private readonly repository: StagingRepository,
// private readonly tourCalculationAdapter: ToursCalculationAdapater,
private readonly tourCalculationAdapter: ToursCalculationAdapater,
) {}
clearStaging(): Promise<Prisma.BatchPayload[]> {
@@ -78,43 +81,48 @@ export class TransformersService {
.filter(({ einsatzNummer }) => !!einsatzNummer)
.map((tour) => TourTransformer.transform(tour)),
),
// switchMap((tours) =>
// from(
// this.tourCalculationAdapter.addRevenueAndConsumptionCostsToTours(
// tours,
// ),
// ),
// ),
switchMap((tours) =>
from(
this.tourCalculationAdapter.addRevenueAndConsumptionCostsToTours(
tours,
),
),
),
switchMap((tours) => this.repository.createTours(tours)),
);
}
transformEmployees(jobId: string) {
return combineLatest([
from(this.extractorsService.findEmployeesDispoLiveByJobId(jobId)),
from(this.extractorsService.findEmployeesDyflexisByJobId(jobId)),
]).pipe(
map(([dispoLiveEmployees, dyflexisEmployees]) =>
dyflexisEmployees
.map((dyflexisEmployee) => {
const dispoLiveEmployee = dispoLiveEmployees.find(
(e) => e.importPersId === dyflexisEmployee.id,
);
if (!dispoLiveEmployee) return undefined;
return [dyflexisEmployee, dispoLiveEmployee];
})
.filter(Boolean),
),
transformDyflexisEmployees(jobId: string) {
return from(
this.extractorsService.findEmployeesDyflexisByJobId(jobId),
).pipe(
map((employees) =>
employees.map(([dyflexisEmployee, dispoLiveEmployee]) =>
EmployeeTransformer.transform(
employees.map((dyflexisEmployee) =>
DyflexisEmployeeTransformer.transform(
dyflexisEmployee as EmployeeDyflexisSource,
),
),
),
switchMap((employees) =>
this.repository.createDyflexisEmployees(employees),
),
);
}
transformDispoliveEmployees(jobId: string) {
return from(
this.extractorsService.findEmployeesDispoLiveByJobId(jobId),
).pipe(
map((employees) =>
employees.map((dispoLiveEmployee) =>
DispoliveEmployeeTransformer.transform(
dispoLiveEmployee as EmployeeDispoLiveSource,
),
),
),
switchMap((employees) => this.repository.createEmployees(employees)),
switchMap((employees) =>
this.repository.createDispoliveEmployees(employees),
),
);
}
@@ -131,6 +139,14 @@ export class TransformersService {
return this.repository.findPatients();
}
findDyflexisEmployees(): Promise<EmployeeDyflexisStaging[]> {
return this.repository.findDyflexisEmployees();
}
findDispoliveEmployees(): Promise<EmployeeDispoLiveStaging[]> {
return this.repository.findDispoliveEmployees();
}
findTours(): Promise<TourStaging[]> {
return this.repository.findTours();
}
@@ -0,0 +1,42 @@
import { EmployeeDispoLiveSource } from '@prisma/client';
import { isValidDate } from 'src/utils';
import { EmployeeDispoLivePreStaging } from '../staging.interface';
import {
parseDateTime,
pick,
transformProperties,
} from '../transformer.functions';
export class DispoliveEmployeeTransformer {
public static transform(
dispoLiveEmployeeSource: EmployeeDispoLiveSource,
): EmployeeDispoLivePreStaging {
const dispoLiveEmployeeParsed = {
...dispoLiveEmployeeSource,
...transformProperties(parseDateTime('yyyy-MM-dd'))(
pick(['pScheinAblaufDatum'])(dispoLiveEmployeeSource),
),
};
const {
id,
name,
surname,
importPersId,
pScheinAblaufDatum,
qualifikation,
} = dispoLiveEmployeeParsed;
return {
id,
name,
surname,
personnelNumber: importPersId,
personTransportCertificate: isValidDate(new Date(pScheinAblaufDatum))
? pScheinAblaufDatum
: undefined,
qualification: qualifikation,
} as unknown as EmployeeDispoLivePreStaging;
}
}
@@ -1,10 +1,6 @@
import {
EmployeeDispoLiveSource,
EmployeeDyflexisSource,
} from '@prisma/client';
import { EmployeeDyflexisSource } from '@prisma/client';
import { isValidDate } from 'src/utils';
import { EmployeePreStaging } from '../staging.interface';
import { EmployeeDyflexisPreStaging } from '../staging.interface';
import {
parseDateTime,
parseFloat,
@@ -13,11 +9,10 @@ import {
transformProperties,
} from '../transformer.functions';
export class EmployeeTransformer {
export class DyflexisEmployeeTransformer {
public static transform(
dyflexisEmployeeSource: EmployeeDyflexisSource,
dispoLiveEmployeeSource: EmployeeDispoLiveSource,
): EmployeePreStaging {
): EmployeeDyflexisPreStaging {
const dyflexisEmployeeParsed = {
...dyflexisEmployeeSource,
...transformProperties(parseFloat)(
@@ -32,12 +27,6 @@ export class EmployeeTransformer {
pick(['contract_start', 'contract_end'])(dyflexisEmployeeSource),
),
};
const dispoLiveEmployeeParsed = {
...dispoLiveEmployeeSource,
...transformProperties(parseDateTime('yyyy-MM-dd'))(
pick(['pScheinAblaufDatum'])(dispoLiveEmployeeSource),
),
};
const {
id,
@@ -48,7 +37,6 @@ export class EmployeeTransformer {
contract_hours_week,
contract_salary_hour,
} = dyflexisEmployeeParsed;
const { pScheinAblaufDatum, qualifikation } = dispoLiveEmployeeParsed;
return {
id,
@@ -58,10 +46,6 @@ export class EmployeeTransformer {
contractEnd: contract_end,
contractHoursWeek: contract_hours_week,
contractSalaryHour: contract_salary_hour,
personTransportCertificate: isValidDate(new Date(pScheinAblaufDatum))
? pScheinAblaufDatum
: undefined,
qualification: qualifikation,
} as unknown as EmployeePreStaging;
} as unknown as EmployeeDyflexisPreStaging;
}
}