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