add business model processing

This commit is contained in:
Marcel Arndt 2024-07-15 12:53:11 +02:00
parent 8be07e1f9b
commit 53e945253c
65 changed files with 10638 additions and 6659 deletions

View File

@ -0,0 +1,21 @@
FROM node:20.15.1-alpine as builder
WORKDIR /app/builder
COPY . .
RUN npm install \
&& npm run build
FROM node:20.15.1-alpine
WORKDIR /app
COPY --from=builder /app/builder/node_modules ./node_modules
COPY --from=builder /app/builder/prisma ./prisma
COPY --from=builder /app/builder/dist .
COPY --from=builder /app/builder/docker-entrypoint.sh .
ENTRYPOINT [ "./docker-entrypoint.sh" ]
CMD [ "node", "./src/main.js" ]

View File

@ -1,6 +1,29 @@
version: '3'
services:
avicenna-data:
image: thedevilisdero/avic-data-connector
ports:
- 3000:3000
env_file: .env
environment:
DATABASE_PORT: ${DATABASE_PORT}
DATABASE_HOST: ${DATABASE_HOST}
DATABASE_USERNAME: ${DATABASE_USERNAME}
DATABASE_PASSWORD: ${DATABASE_PASSWORD}
DATABASE_DATABASE: ${DATABASE_DATABASE}
DATABASE_URL: ${DATABASE_URL}
DISPO_LIVE_BASE_URL: ${DISPO_LIVE_BASE_URL}
DISPO_LIVE_USERNAME: ${DISPO_LIVE_USERNAME}
DISPO_LIVE_PASSWORD: ${DISPO_LIVE_PASSWORD}
DYFLEXIS_V0_BASE_URL: ${DYFLEXIS_V0_BASE_URL}
DYFLEXIS_V2_BASE_URL: ${DYFLEXIS_V2_BASE_URL}
DYFLEXIS_V0_API_KEY: ${DYFLEXIS_V0_API_KEY}
DYFLEXIS_V2_API_KEY: ${DYFLEXIS_V2_API_KEY}
networks:
- avic
avicenna-db:
image: postgres:16.3
ports:
@ -14,6 +37,11 @@ services:
PG_DATA: /var/lib/postgresql/data
volumes:
- ./.local/pgdata:/var/lib/postgresql/data
networks:
- avic
volumes:
pgdata:
networks:
avic:

View File

@ -0,0 +1,4 @@
#!/bin/sh
npx prisma generate
exec "$@"

View File

@ -0,0 +1,535 @@
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export const TicketState = {
CREATED: "CREATED",
APPROVAL_REQUIRED: "APPROVAL_REQUIRED",
READY: "READY",
DONE: "DONE",
BILLABLE: "BILLABLE",
ARCHIVED: "ARCHIVED"
} as const;
export type TicketState = (typeof TicketState)[keyof typeof TicketState];
export const TicketValidationState = {
CREATED: "CREATED",
USER_INTERACTION_REQUIRED: "USER_INTERACTION_REQUIRED",
TD_UNCERTAIN: "TD_UNCERTAIN",
DOCUMENTS_MISSING: "DOCUMENTS_MISSING",
ARCHIVED: "ARCHIVED"
} as const;
export type TicketValidationState = (typeof TicketValidationState)[keyof typeof TicketValidationState];
export const ApprovalState = {
UNKNOWN: "UNKNOWN",
FREE: "FREE",
REQUIRED: "REQUIRED",
REQUESTED: "REQUESTED",
APPROVED: "APPROVED"
} as const;
export type ApprovalState = (typeof ApprovalState)[keyof typeof ApprovalState];
export const CostTypeVariant = {
NACHTZUSCHLAG: "NACHTZUSCHLAG",
WOCHENENDZUSCHLAG: "WOCHENENDZUSCHLAG",
FEIERTAGSZUSCHLAG: "FEIERTAGSZUSCHLAG",
DESINFEKTIONSZUSCHLAG: "DESINFEKTIONSZUSCHLAG",
KILOMETERZUSCHLAG: "KILOMETERZUSCHLAG",
FAHRZEUGZUSCHLAG: "FAHRZEUGZUSCHLAG",
TSWDIFFERENZIERUNG: "TSWDIFFERENZIERUNG",
GRUNDPREIS: "GRUNDPREIS"
} as const;
export type CostTypeVariant = (typeof CostTypeVariant)[keyof typeof CostTypeVariant];
export type Anomaly = {
id: string;
type: string;
description: string;
groupKey: string;
groupDescription: string;
workedOnBy: string;
sleepTimer: number;
sleepSince: Timestamp | null;
resolvedAt: Timestamp | null;
manuallyResolved: Generated<boolean>;
createdAt: Generated<Timestamp>;
};
export type AttendanceRegistration = {
attendanceRegistrationId: number;
employeeId: number;
personnelNumber: string;
dateTime: Timestamp;
event: string;
};
export type AttendanceRegistrationSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
attendanceRegistrationId: string | null;
employeeId: string | null;
personnelNumber: string | null;
dateTime: string | null;
event: string | null;
};
export type AttendanceRegistrationStaging = {
internalId: Generated<string>;
attendanceRegistrationId: number;
employeeId: number;
dateTime: Timestamp;
event: string;
};
export type AvicennaTenant = {
id: Generated<string>;
healthInsuranceNumber: string;
name: string;
};
export type AvicennaTenantToTariffGroup = {
A: string;
B: string;
};
export type CostType = {
id: Generated<string>;
description: string;
variant: CostTypeVariant;
costAmount: number;
timeRange: string | null;
kmInclusive: number | null;
excludeCarNames: string[];
infectionType: string | null;
tariffId: string | null;
};
export type DyflexisEmployee = {
id: number;
firstname: string;
surname: string;
contractStart: Timestamp;
contractEnd: Timestamp | null;
contractHoursWeek: number;
contractSalaryHour: number;
};
export type Employee = {
id: string;
name: string;
surname: string;
personnelNumber: string | null;
mobile: string | null;
qualification: string | null;
occuptationalHealthExamination: Timestamp | null;
personTransportCertificate: Timestamp | null;
};
export type EmployeeDispoLiveSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
pScheinAblaufDatum: string | null;
name: string | null;
surname: string | null;
persId: string | null;
importPersId: string | null;
qualifikation: string | null;
};
export type EmployeeDispoLiveStaging = {
internalId: Generated<string>;
id: string;
name: string | null;
surname: string | null;
personnelNumber: string | null;
qualification: string | null;
personTransportCertificate: Timestamp | null;
};
export type EmployeeDyflexisSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
firstname: string | null;
surname: string | null;
contract_id: string | null;
contract_start: string | null;
contract_end: string | null;
contract_hours_week: string | null;
contract_salary_hour: string | null;
};
export type EmployeeDyflexisStaging = {
internalId: Generated<string>;
id: number;
firstname: string;
surname: string;
contractStart: Timestamp;
contractEnd: Timestamp;
contractHoursWeek: number;
contractSalaryHour: number;
};
export type Patient = {
id: string;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
};
export type PatientSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: string | null;
kkId: string | null;
krankenkasse: string | null;
pflegestufe: string | null;
behinderung1: string | null;
behinderung2: string | null;
};
export type PatientStaging = {
internalId: Generated<string>;
id: string | null;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
};
export type PlannedTime = {
id: string;
userId: string;
firstname: string | null;
surname: string | null;
startDate: Timestamp | null;
endDate: Timestamp | null;
department: string | null;
pause: number;
duration: number;
note: string | null;
};
export type PlannedTimeSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
user_id: string | null;
firstname: string | null;
surname: string | null;
department_id: string | null;
department_name: string | null;
start_date: string | null;
end_date: string | null;
pauze: string | null;
duration: string | null;
deleted: string | null;
mark: string | null;
};
export type PlannedTimeStaging = {
internalId: Generated<string>;
id: number;
employeeId: number;
firstname: string | null;
surname: string | null;
departmentName: string | null;
startDate: Timestamp;
endDate: Timestamp;
deleted: boolean;
mark: string | null;
};
export type SolutionTarget = {
id: Generated<string>;
name: string;
anomalyId: string | null;
};
export type SolutionTargetStep = {
id: Generated<string>;
description: string;
solutionTargetId: string | null;
};
export type Tariff = {
id: Generated<string>;
validFrom: Timestamp | null;
validTo: Timestamp | null;
tariffGroupId: string | null;
};
export type TariffGroup = {
id: Generated<string>;
description: string;
ordinanceType: string;
};
export type Ticket = {
id: Generated<string>;
currentState: TicketValidationState;
notes: string[];
errors: string[];
isUrgent: Generated<boolean>;
urgency: Generated<Timestamp>;
approvalState: ApprovalState | null;
documentInfo: Generated<unknown | null>;
createdAt: Generated<Timestamp>;
};
export type TicketStateHistory = {
id: Generated<string>;
state: TicketValidationState;
createdAt: Generated<Timestamp>;
ticketId: string;
};
export type Tour = {
id: Generated<string>;
done: boolean;
direction: string;
carName: string | null;
operationId: string;
driverId: string | null;
driverName: string | null;
codriverId: string | null;
codriverName: string | null;
patientId: string | null;
patientName: string | null;
patientSurname: string | null;
patientStreet: string | null;
patientZip: string | null;
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
type: string | null;
category: string | null;
transportType: string | null;
ordinanceType: string | null;
rangeEndDate: Timestamp | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
startDate: Timestamp;
check: Generated<number>;
occupiedKm: number | null;
totalKm: number | null;
startBegin: string | null;
startEnd: string | null;
target: string | null;
targetBegin: string | null;
empty: string | null;
hasInfection: Generated<boolean>;
infectionName: string | null;
revenueDispoLive: number | null;
billDate: Timestamp | null;
billNumber: string | null;
revenue: Generated<number>;
consumptionCosts: Generated<number>;
revenueDeviation: Generated<number>;
createdAt: Timestamp | null;
deletedAt: Timestamp | null;
ticketId: string | null;
};
export type TourSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
check: string | null;
createTime: string | null;
empfangenVonId: string | null;
endgen: string | null;
serienId: string | null;
serienKategorName: string | null;
serienKategorie: string | null;
zadStatus: string | null;
abrDate: string | null;
abrNum: string | null;
abrTime: string | null;
ausrueckZeit: string | null;
startTime: string | null;
startDate: string | null;
date: string | null;
date_iso: string | null;
dauergenehmigung: string | null;
direction: string | null;
distanceValue: string | null;
besetztKm: string | null;
gefahren: string | null;
gesamtPreis: string | null;
infektion: string | null;
itDesc: string | null;
itKrz: string | null;
itName: string | null;
kkId: string | null;
krankenkasse: string | null;
patId: string | null;
patName: string | null;
patSurname: string | null;
patStreet: string | null;
patZip: string | null;
patCity: string | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
transportKrz: string | null;
transportName: string | null;
transportart: string | null;
verordnungsId: string | null;
verordnungsName: string | null;
type: string | null;
startBegin: string | null;
startEnd: string | null;
frei: string | null;
ziel: string | null;
zielBegin: string | null;
endTime: string | null;
endDate: string | null;
einsatzNummer: string | null;
carName: string | null;
carNo: string | null;
beifahrerName: string | null;
beifahrer: string | null;
fahrerName: string | null;
fahrer: string | null;
color: string | null;
};
export type TourStaging = {
internalId: Generated<string>;
id: string | null;
done: boolean | null;
direction: string | null;
carName: string | null;
operationId: string | null;
driverId: string | null;
driverName: string | null;
codriverId: string | null;
codriverName: string | null;
patientId: string | null;
patientName: string | null;
patientSurname: string | null;
patientStreet: string | null;
patientZip: string | null;
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
type: string | null;
category: string | null;
transportType: string | null;
ordinanceType: string | null;
rangeEndDate: Timestamp | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
startDate: Timestamp | null;
check: Generated<number>;
occupiedKm: number | null;
totalKm: number | null;
startBegin: string | null;
startEnd: string | null;
target: string | null;
targetBegin: string | null;
empty: string | null;
hasInfection: Generated<boolean>;
infectionName: string | null;
revenueDispoLive: number | null;
billDate: Timestamp | null;
billNumber: string | null;
revenue: Generated<number>;
revenueDeviation: Generated<number>;
consumptionCosts: Generated<number>;
createdAt: Timestamp | null;
};
export type Trace = {
id: Generated<string>;
type: string;
entityKey: string;
createdAt: Generated<Timestamp>;
};
export type WorkTime = {
id: string;
userId: string;
firstname: string;
surname: string;
startDate: Timestamp | null;
endDate: Timestamp | null;
department: string | null;
pause: number;
duration: number;
};
export type WorkTimeSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
user_id: string | null;
firstname: string | null;
surname: string | null;
department_id: string | null;
department_name: string | null;
start_date: string | null;
end_date: string | null;
pauze: string | null;
duration: string | null;
};
export type WorkTimeStaging = {
internalId: Generated<string>;
id: number;
employeeId: number;
firstname: string;
surname: string;
departmentName: string | null;
startDate: Timestamp;
endDate: Timestamp;
};
export type DB = {
_AvicennaTenantToTariffGroup: AvicennaTenantToTariffGroup;
Anomaly: Anomaly;
AttendanceRegistration: AttendanceRegistration;
AttendanceRegistrationSource: AttendanceRegistrationSource;
AttendanceRegistrationStaging: AttendanceRegistrationStaging;
AvicennaTenant: AvicennaTenant;
CostType: CostType;
DyflexisEmployee: DyflexisEmployee;
Employee: Employee;
EmployeeDispoLiveSource: EmployeeDispoLiveSource;
EmployeeDispoLiveStaging: EmployeeDispoLiveStaging;
EmployeeDyflexisSource: EmployeeDyflexisSource;
EmployeeDyflexisStaging: EmployeeDyflexisStaging;
Patient: Patient;
PatientSource: PatientSource;
PatientStaging: PatientStaging;
PlannedTime: PlannedTime;
PlannedTimeSource: PlannedTimeSource;
PlannedTimeStaging: PlannedTimeStaging;
SolutionTarget: SolutionTarget;
SolutionTargetStep: SolutionTargetStep;
Tariff: Tariff;
TariffGroup: TariffGroup;
Ticket: Ticket;
TicketStateHistory: TicketStateHistory;
Tour: Tour;
TourSource: TourSource;
TourStaging: TourStaging;
Trace: Trace;
WorkTime: WorkTime;
WorkTimeSource: WorkTimeSource;
WorkTimeStaging: WorkTimeStaging;
};

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,9 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"prisma": {
"seed": "ts-node prisma/seed/seed.ts"
},
"dependencies": {
"@nestjs/axios": "^3.0.2",
"@nestjs/common": "^10.0.0",
@ -30,6 +33,9 @@
"@prisma/client": "^5.16.2",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.1.3",
"kysely": "^0.27.4",
"pg": "^8.12.0",
"prisma-kysely": "^1.8.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},

View File

@ -0,0 +1,184 @@
/*
Warnings:
- You are about to drop the `EmployeeStaging` table. If the table is not empty, all the data it contains will be lost.
*/
-- CreateEnum
CREATE TYPE "CostTypeVariant" AS ENUM ('NACHTZUSCHLAG', 'WOCHENENDZUSCHLAG', 'FEIERTAGSZUSCHLAG', 'DESINFEKTIONSZUSCHLAG', 'KILOMETERZUSCHLAG', 'FAHRZEUGZUSCHLAG', 'TSWDIFFERENZIERUNG', 'GRUNDPREIS');
-- DropTable
DROP TABLE "EmployeeStaging";
-- CreateTable
CREATE TABLE "EmployeeDyflexisStaging" (
"internalId" UUID NOT NULL DEFAULT gen_random_uuid(),
"id" INTEGER NOT NULL,
"firstname" TEXT NOT NULL,
"surname" TEXT NOT NULL,
"contractStart" TIMESTAMP(3) NOT NULL,
"contractEnd" TIMESTAMP(3) NOT NULL,
"contractHoursWeek" DOUBLE PRECISION NOT NULL,
"contractSalaryHour" DOUBLE PRECISION NOT NULL,
CONSTRAINT "EmployeeDyflexisStaging_pkey" PRIMARY KEY ("internalId")
);
-- CreateTable
CREATE TABLE "EmployeeDispoLiveStaging" (
"internalId" UUID NOT NULL DEFAULT gen_random_uuid(),
"id" TEXT NOT NULL,
"name" TEXT,
"surname" TEXT,
"personnelNumber" TEXT,
"qualification" TEXT,
"personTransportCertificate" TIMESTAMP(3),
CONSTRAINT "EmployeeDispoLiveStaging_pkey" PRIMARY KEY ("internalId")
);
-- CreateTable
CREATE TABLE "Employee" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"surname" TEXT NOT NULL,
"personnelNumber" TEXT,
"mobile" TEXT,
"qualification" TEXT,
"occuptationalHealthExamination" TIMESTAMP(3),
"personTransportCertificate" TIMESTAMP(3),
CONSTRAINT "Employee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DyflexisEmployee" (
"id" INTEGER NOT NULL,
"firstname" TEXT NOT NULL,
"surname" TEXT NOT NULL,
"contractStart" TIMESTAMP(3) NOT NULL,
"contractEnd" TIMESTAMP(3),
"contractHoursWeek" DOUBLE PRECISION NOT NULL,
"contractSalaryHour" INTEGER NOT NULL,
CONSTRAINT "DyflexisEmployee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AvicennaTenant" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"healthInsuranceNumber" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "AvicennaTenant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TariffGroup" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"description" TEXT NOT NULL,
"ordinanceType" TEXT NOT NULL,
CONSTRAINT "TariffGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tariff" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"validFrom" TIMESTAMP(3),
"validTo" TIMESTAMP(3),
"tariffGroupId" UUID,
CONSTRAINT "Tariff_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CostType" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"description" TEXT NOT NULL,
"variant" "CostTypeVariant" NOT NULL,
"costAmount" INTEGER NOT NULL,
"timeRange" TEXT,
"kmInclusive" INTEGER,
"excludeCarNames" TEXT[],
"infectionType" TEXT,
"tariffId" UUID
);
-- CreateTable
CREATE TABLE "Trace" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"type" TEXT NOT NULL,
"entityKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Trace_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Anomaly" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"groupKey" TEXT NOT NULL,
"groupDescription" TEXT NOT NULL,
"workedOnBy" TEXT NOT NULL,
"sleepTimer" INTEGER NOT NULL,
"sleepSince" TIMESTAMP(3),
"resolvedAt" TIMESTAMP(3),
"manuallyResolved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anomaly_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SolutionTarget" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"name" TEXT NOT NULL,
"anomalyId" TEXT,
CONSTRAINT "SolutionTarget_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SolutionTargetStep" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"description" TEXT NOT NULL,
"solutionTargetId" UUID,
CONSTRAINT "SolutionTargetStep_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_AvicennaTenantToTariffGroup" (
"A" UUID NOT NULL,
"B" UUID NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "CostType_id_key" ON "CostType"("id");
-- CreateIndex
CREATE UNIQUE INDEX "_AvicennaTenantToTariffGroup_AB_unique" ON "_AvicennaTenantToTariffGroup"("A", "B");
-- CreateIndex
CREATE INDEX "_AvicennaTenantToTariffGroup_B_index" ON "_AvicennaTenantToTariffGroup"("B");
-- AddForeignKey
ALTER TABLE "Tariff" ADD CONSTRAINT "Tariff_tariffGroupId_fkey" FOREIGN KEY ("tariffGroupId") REFERENCES "TariffGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CostType" ADD CONSTRAINT "CostType_tariffId_fkey" FOREIGN KEY ("tariffId") REFERENCES "Tariff"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SolutionTarget" ADD CONSTRAINT "SolutionTarget_anomalyId_fkey" FOREIGN KEY ("anomalyId") REFERENCES "Anomaly"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SolutionTargetStep" ADD CONSTRAINT "SolutionTargetStep_solutionTargetId_fkey" FOREIGN KEY ("solutionTargetId") REFERENCES "SolutionTarget"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AvicennaTenantToTariffGroup" ADD CONSTRAINT "_AvicennaTenantToTariffGroup_A_fkey" FOREIGN KEY ("A") REFERENCES "AvicennaTenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AvicennaTenantToTariffGroup" ADD CONSTRAINT "_AvicennaTenantToTariffGroup_B_fkey" FOREIGN KEY ("B") REFERENCES "TariffGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -6,7 +6,15 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions", "fieldReference"]
previewFeatures = ["postgresqlExtensions"]
}
generator kysely {
provider = "prisma-kysely"
previewFeatures = ["postgresqlExtensions"]
output = "../libs/api/data-access-database/src/lib"
fileName = "types.ts"
}
datasource db {
@ -303,18 +311,27 @@ model WorkTimeStaging {
endDate DateTime
}
model EmployeeStaging {
model EmployeeDyflexisStaging {
internalId String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
id Int
firstname String
surname String
contractStart DateTime
contractEnd DateTime
contractHoursWeek Float
contractSalaryHour Float
personTransportCertificate DateTime?
qualification String?
id Int
firstname String
surname String
contractStart DateTime
contractEnd DateTime
contractHoursWeek Float
contractSalaryHour Float
}
model EmployeeDispoLiveStaging {
internalId String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
id String
name String?
surname String?
personnelNumber String? // Personal ID aus Dyflexis
qualification String? // ID
personTransportCertificate DateTime? // yyyy-MM-dd
}
model Tour {
@ -478,3 +495,114 @@ model PlannedTime {
duration Int
note String?
}
model Employee {
id String @id
name String
surname String
personnelNumber String?
mobile String?
qualification String?
occuptationalHealthExamination DateTime?
personTransportCertificate DateTime?
}
model DyflexisEmployee {
id Int @id
firstname String
surname String
contractStart DateTime
contractEnd DateTime?
contractHoursWeek Float
contractSalaryHour Int
}
enum CostTypeVariant {
NACHTZUSCHLAG
WOCHENENDZUSCHLAG
FEIERTAGSZUSCHLAG
DESINFEKTIONSZUSCHLAG
KILOMETERZUSCHLAG
FAHRZEUGZUSCHLAG
TSWDIFFERENZIERUNG
GRUNDPREIS
}
model AvicennaTenant {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
healthInsuranceNumber String
name String
tariffGroups TariffGroup[]
}
model TariffGroup {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
description String
ordinanceType String
tenants AvicennaTenant[]
tariffs Tariff[]
}
model Tariff {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
validFrom DateTime?
validTo DateTime?
costTypes CostType[]
tariffGroup TariffGroup? @relation(fields: [tariffGroupId], references: [id])
tariffGroupId String? @db.Uuid
}
model CostType {
id String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid
description String
variant CostTypeVariant
costAmount Int
timeRange String?
kmInclusive Int?
excludeCarNames String[]
infectionType String?
tariff Tariff? @relation(fields: [tariffId], references: [id])
tariffId String? @db.Uuid
}
model Trace {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
type String
entityKey String
createdAt DateTime @default(now())
}
model Anomaly {
id String @id
type String
description String
groupKey String
groupDescription String
workedOnBy String
sleepTimer Int
sleepSince DateTime?
solution SolutionTarget[] @relation()
resolvedAt DateTime?
manuallyResolved Boolean @default(false)
createdAt DateTime @default(now())
}
model SolutionTarget {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
steps SolutionTargetStep[] @relation()
anomaly Anomaly? @relation(fields: [anomalyId], references: [id])
anomalyId String?
}
model SolutionTargetStep {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
description String
solutionTarget SolutionTarget? @relation(fields: [solutionTargetId], references: [id])
solutionTargetId String? @db.Uuid
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,141 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay, endOfDay } from 'date-fns';
export const BTW_AOK_TARIFFS = [
{
description: 'AOK/BKK/IKK/Knappschaft BTW',
validFrom: startOfDay(new Date('2021-01-01')),
validTo: endOfDay(new Date('2021-06-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 3400,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 135,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 1665,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 1665,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 1665,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft BTW',
validFrom: startOfDay(new Date('2021-07-01')),
validTo: endOfDay(new Date('2022-08-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 3465,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 135,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 1665,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 1665,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 1665,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft BTW',
validFrom: startOfDay(new Date('2022-09-01')),
validTo: endOfDay(new Date('2023-04-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 3700,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 160,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 1765,
timeRange: '18:00-07:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 1765,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 1765,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft BTW',
validFrom: startOfDay(new Date('2023-05-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 3885,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 180,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 1865,
timeRange: '18:00-07:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 1865,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 1865,
},
],
},
];

View File

@ -0,0 +1,41 @@
import { CostTypeVariant } from '@prisma/client';
import { endOfDay, startOfDay } from 'date-fns';
export const BTW_DAK_VDEK_TARIFFS = [
{
description: 'DAK + VDEK BTW',
validFrom: startOfDay(new Date('2021-01-01')),
validTo: endOfDay(new Date('2023-05-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 3700,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 159,
kmInclusive: 20,
},
],
},
{
description: 'DAK + VDEK BTW',
validFrom: startOfDay(new Date('2023-06-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 4171,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 166,
kmInclusive: 21,
},
],
},
];

View File

@ -0,0 +1,44 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay } from 'date-fns';
export const BTW_PRIVAT_TARIFFS = [
{
description: 'Privat BTW',
validFrom: startOfDay(new Date('2020-01-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 6500,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2000,
},
{
description: 'ab dem 16. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 250,
kmInclusive: 15,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4000,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4000,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4000,
},
],
},
];

View File

@ -0,0 +1,304 @@
import { CostTypeVariant } from '@prisma/client';
import { endOfDay, startOfDay } from 'date-fns';
export const KTW_AOK_TARIFFS = [
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2019-04-01')),
validTo: endOfDay(new Date('2021-04-22')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8936,
},
{
description: 'Desinfektionszuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 70,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 286,
kmInclusive: 18,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4003,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4003,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4003,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2021-04-23')),
validTo: endOfDay(new Date('2021-06-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8936,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 177,
},
{
description: 'Desinfektionszuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 70,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4003,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4003,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4003,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 286,
kmInclusive: 18,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2021-07-01')),
validTo: endOfDay(new Date('2023-04-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 9162,
},
{
description: 'Desinfektionszuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 72,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 181,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4104,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4104,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4104,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 293,
kmInclusive: 18,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2023-05-01')),
validTo: endOfDay(new Date('2023-12-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 7839,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2006,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.FAHRZEUGZUSCHLAG,
costAmount: 1121,
excludeCarNames: [
'1/KTW-01',
'1/KTW-02',
'1/KTW-03',
'1/KTW-04',
'1/KTW-05',
'1/KTW-06',
]
},
{
description: 'Leitstellenmanagement',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 485,
},
{
description: 'Ausbildungsbetrieb Rettungssanitäter',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 298,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 320,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 6500,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 6500,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 6500,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 3839,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 15732,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 18191,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 18191,
infectionType: '3.2',
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2024-01-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 6921,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2829,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.FAHRZEUGZUSCHLAG,
costAmount: 1428,
excludeCarNames: [
'1/KTW-01',
'1/KTW-02',
'1/KTW-03',
'1/KTW-04',
'1/KTW-05-E',
'1/KTW-06',
]
},
{
description: 'Leitstellenmanagement',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 665,
},
{
description: 'Ausbildungsbetrieb Rettungssanitäter',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 305,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 359,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 6919,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 6919,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 6919,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 3935,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 16126,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 18646,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 18646,
infectionType: '3.2',
},
],
},
];

View File

@ -0,0 +1,300 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay, endOfDay } from 'date-fns';
export const KTW_DAK_TARIFFS = [
{
description: 'DAK KTW',
validFrom: startOfDay(new Date('2018-02-01')),
validTo: endOfDay(new Date('2021-04-22')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8507,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 284,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4728,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4728,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4728,
},
],
},
{
description: 'DAK KTW',
validFrom: startOfDay(new Date('2021-04-23')),
validTo: endOfDay(new Date('2021-09-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8507,
},
{
description: 'QMS Zuschlag inkl. Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 790,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 284,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4728,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4728,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4728,
},
],
},
{
description: 'DAK KTW',
validFrom: startOfDay(new Date('2021-10-01')),
validTo: endOfDay(new Date('2022-12-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 7650,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1380,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 750,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 300,
kmInclusive: 16,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5300,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 5230,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 5230,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 3590,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 14350,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 16500,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 16500,
infectionType: '3.2',
},
],
},
{
description: 'DAK KTW',
validFrom: startOfDay(new Date('2023-01-01')),
validTo: endOfDay(new Date('2023-12-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 7920,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1430,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 780,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 315,
kmInclusive: 16,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5485,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 5410,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 5410,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 3714,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 14845,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17069,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17069,
infectionType: '3.2',
},
],
},
{
description: 'DAK KTW',
validFrom: startOfDay(new Date('2024-01-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 9000,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2000,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1000,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 330,
kmInclusive: 16,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5728,
timeRange: '19:00-19:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 6644,
timeRange: '20:00-21:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 7198,
timeRange: '22:00-04:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5728,
timeRange: '05:00-06:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 5410,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 5410,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 3714,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 14845,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17069,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17069,
infectionType: '3.2',
},
],
},
];

View File

@ -0,0 +1,68 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay } from 'date-fns';
export const KTW_PRIVAT_TARIFFS = [
{
description: 'Privat KTW',
validFrom: startOfDay(new Date('2020-01-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 19000,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2500,
},
{
description: 'ab dem 16. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 390,
kmInclusive: 15,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 8800,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 8800,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 8800,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 8800,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 8800,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17500,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17500,
infectionType: '3.2',
},
],
},
];

View File

@ -0,0 +1,281 @@
import { CostTypeVariant } from '@prisma/client';
import { endOfDay, startOfDay } from 'date-fns';
export const KTW_VDEK_TARIFFS = [
{
description: 'VDEK KTW',
validFrom: startOfDay(new Date('2017-09-01')),
validTo: endOfDay(new Date('2021-04-22')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8150,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 273,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4550,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4550,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4550,
},
],
},
{
description: 'VDEK KTW',
validFrom: startOfDay(new Date('2021-04-23')),
validTo: endOfDay(new Date('2021-06-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8150,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 950,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 273,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 4550,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 4550,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 4550,
},
],
},
{
description: 'VDEK KTW',
validFrom: startOfDay(new Date('2021-07-01')),
validTo: endOfDay(new Date('2021-12-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 8977,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1037,
},
{
description: 'ab dem 18. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 295,
kmInclusive: 17,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5028,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 5028,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 5028,
},
],
},
{
description: 'VDEK KTW',
validFrom: startOfDay(new Date('2022-01-01')),
validTo: endOfDay(new Date('2023-04-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 7043,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2790,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1393,
},
{
description: 'Leitstellenmanagement',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 649,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 317,
kmInclusive: 16,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5537,
timeRange: '18:30-06:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 6212,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 6212,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 4134,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 16941,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 19590,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 19590,
infectionType: '3.2',
},
],
},
{
description: 'VDEK KTW',
validFrom: startOfDay(new Date('2023-05-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 7325,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2886,
},
{
description: 'Modernes Einsatzfahrzeug',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 1450,
},
{
description: 'Leitstellenmanagement',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 671,
},
{
description: 'ab dem 17. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 349,
kmInclusive: 16,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5728,
timeRange: '19:00-19:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 6644,
timeRange: '20:00-21:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 7198,
timeRange: '22:00-04:59',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5728,
timeRange: '05:00-06:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 6426,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 6426,
},
{
description: 'Desinfektionszuschlag 2.A',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 4277,
infectionType: '2.A',
},
{
description: 'Desinfektionszuschlag 2.B',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 17525,
infectionType: '2.B',
},
{
description: 'Desinfektionszuschlag 3.1',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 20266,
infectionType: '3.1',
},
{
description: 'Desinfektionszuschlag 3.2',
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
costAmount: 20266,
infectionType: '3.2',
},
],
},
];

View File

@ -0,0 +1,141 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay, endOfDay } from 'date-fns';
export const TSW_AOK_TARIFFS = [
{
description: 'AOK/BKK/IKK/Knappschaft TSW',
validFrom: startOfDay(new Date('2021-01-01')),
validTo: endOfDay(new Date('2021-06-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 5000,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 170,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 2445,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 2445,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 2445,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft TSW',
validFrom: startOfDay(new Date('2021-07-01')),
validTo: endOfDay(new Date('2022-08-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 5100,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 170,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 2445,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 2445,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 2445,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft TSW',
validFrom: startOfDay(new Date('2022-09-01')),
validTo: endOfDay(new Date('2023-04-30')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 5450,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 180,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 2600,
timeRange: '18:00-07:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 2600,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 2600,
},
],
},
{
description: 'AOK/BKK/IKK/Knappschaft TSW',
validFrom: startOfDay(new Date('2023-05-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 5800,
},
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 190,
kmInclusive: 20,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 2700,
timeRange: '18:00-07:30',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 2700,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 2700,
},
],
},
];

View File

@ -0,0 +1,58 @@
import { CostTypeVariant } from '@prisma/client';
import { endOfDay, startOfDay } from 'date-fns';
export const TSW_DAK_VDEK_TARIFFS = [
{
description: 'DAK + VDEK TSW',
validFrom: startOfDay(new Date('2021-01-01')),
validTo: endOfDay(new Date('2023-05-31')),
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 4922,
},
{
description: 'ab dem 22. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 159,
kmInclusive: 21,
},
],
},
{
description: 'DAK + VDEK TSW',
validFrom: startOfDay(new Date('2023-06-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt (TSW 2)',
variant: CostTypeVariant.TSWDIFFERENZIERUNG,
costAmount: 5880,
},
{
description: 'Grundpauschale je Fahrt (TSW 1)',
variant: CostTypeVariant.TSWDIFFERENZIERUNG,
costAmount: 4380,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 407,
timeRange: '18:30-20:00',
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 407,
timeRange: '06:00-07:00',
},
{
description: 'ab dem 22. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 159,
kmInclusive: 21,
},
],
},
];

View File

@ -0,0 +1,44 @@
import { CostTypeVariant } from '@prisma/client';
import { startOfDay } from 'date-fns';
export const TSW_PRIVAT_TARIFFS = [
{
description: 'Privat TSW',
validFrom: startOfDay(new Date('2020-01-01')),
validTo: null,
costTypes: [
{
description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 11500,
},
{
description: 'QMS Zuschlag',
variant: CostTypeVariant.GRUNDPREIS,
costAmount: 2000,
},
{
description: 'ab dem 16. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 250,
kmInclusive: 15,
},
{
description: 'Nachtzuschlag',
variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 5500,
timeRange: '19:00-07:00',
},
{
description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
costAmount: 5500,
},
{
description: 'Feiertagszuschlag',
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
costAmount: 5500,
},
],
},
];

View File

@ -6,14 +6,30 @@ import { AppService } from './app.service';
import { PrismaModule } from './core/database/prisma.module';
import { FeatureImporterModule } from './modules/feat-importers/feature-importer.module';
import { ConfigModule } from './core/config/config.module';
import { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
import { DatabaseModule } from './core/database/database.module';
import { PostgresDialect } from 'kysely';
import { Pool } from 'pg';
@Module({
imports: [
ConfigModule,
DatabaseModule.forRoot({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
port: 5432,
database: 'avicenna',
user: 'johndoe',
password: 'randompassword',
}),
}),
}),
PrismaModule.forRoot(),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
FeatureImporterModule,
TicketSystemModule,
],
controllers: [AppController],
providers: [AppService],

View File

@ -1,8 +1,7 @@
// import { TicketService } from '@dks/avicenna/api-feature-ticket-system';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
Interval,
Interval as TimeInterval,
addDays,
eachDayOfInterval,
endOfDay,
@ -10,15 +9,16 @@ import {
set,
startOfDay,
} from 'date-fns';
import { concatMap, from, lastValueFrom, take, tap } from 'rxjs';
import { concatMap, from, lastValueFrom, switchMap, take, tap } from 'rxjs';
import { ImporterService } from './modules/feat-importers/importer.service';
import { TicketService } from './modules/feat-tickets/tickets.service';
import { chunk } from './utils';
@Injectable()
export class AppService {
private readonly logger = new Logger(AppService.name);
@Cron('0 */5 2-20 * * *')
// @Cron('0 */5 2-20 * * *')
async fetchDataContinously() {
const now = new Date();
const fetchInterval = {
@ -34,7 +34,7 @@ export class AppService {
`import took ${(Date.now() - now.getTime()) / 1000}s`,
),
),
// switchMap(() => from(this.ticketService.createTickets(fetchInterval))),
switchMap(() => from(this.ticketService.createTickets(fetchInterval))),
)
.subscribe();
}
@ -46,7 +46,7 @@ export class AppService {
if (timeouts.length) return;
const now = new Date();
const fetchInterval: Interval = {
const fetchInterval: TimeInterval = {
start: set(new Date(), {
year: now.getFullYear(),
month: 0,
@ -70,7 +70,7 @@ export class AppService {
from([interval])
.pipe(
concatMap((interval) => this.importer.runImport(interval)),
// switchMap(() => from(this.ticketService.createTickets(interval))),
switchMap(() => from(this.ticketService.createTickets(interval))),
)
.subscribe();
};
@ -81,7 +81,7 @@ export class AppService {
// @Cron('0 2,7,12,17,22,27,32,37,42,47,52,57 2-20 * * *')
async fetchDataSixWeeks() {
const now = new Date();
const fetchInterval: Interval = {
const fetchInterval: TimeInterval = {
start: startOfDay(addDays(now, -90)),
end: endOfDay(now),
};
@ -111,7 +111,7 @@ export class AppService {
// @Cron('* * 0 * * *')
async fetchDataYearly() {
const now = new Date();
const fetchInterval: Interval = {
const fetchInterval: TimeInterval = {
start: set(new Date(), {
year: now.getFullYear(),
month: 0,
@ -137,21 +137,21 @@ export class AppService {
),
),
),
// switchMap(() => from(this.ticketService.createTickets(fetchInterval))),
switchMap(() => from(this.ticketService.createTickets(fetchInterval))),
)
.subscribe();
}
constructor(
private readonly importer: ImporterService,
// private readonly ticketService: TicketService,
private readonly ticketService: TicketService,
private readonly schedulerRegistry: SchedulerRegistry,
) {}
private splitIntervalIntoPeriods(
interval: Interval,
interval: TimeInterval,
chunkSize = 30,
): Interval[] {
): TimeInterval[] {
return chunk(eachDayOfInterval(interval), chunkSize).map((dates) => ({
start: startOfDay(dates.at(0) as Date),
end: endOfDay(dates.at(-1) as Date),

View File

@ -0,0 +1,4 @@
export enum AttendanceRegistrationEvents {
ClockedIn = 'clocked in',
ClockedOut = 'clocked out',
}

View File

@ -0,0 +1,6 @@
export enum TraceTypes {
AnomalyAccepted = 'anomaly.accepted',
AnomalyIgnored = 'anomaly.ignored',
TourWarning = 'tour.warning',
TourCritical = 'tour.critical',
}

View File

@ -1 +1 @@
export const PRISMA_SERVICE_OPTIONS = 'PRISMA_SERVICE_OPTIONS';
export const PRISMA_SERVICE_OPTIONS = 'PRISMA_SERVICE_OPTIONS';

View File

@ -0,0 +1,19 @@
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
import { KyselyConfig } from 'kysely';
import { createKyselyProvider } from './kysely-client.factory';
export const KYSELY_CLIENT_PROVIDER_TOKEN = 'KyselyClientProviderToken';
@Global()
@Module({})
export class DatabaseModule {
public static forRoot(config: KyselyConfig): DynamicModule {
const provider: Provider = createKyselyProvider(config);
return {
module: DatabaseModule,
exports: [provider],
providers: [provider],
};
}
}

View File

@ -0,0 +1,12 @@
import { Provider } from '@nestjs/common';
import { Kysely, KyselyConfig } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from './database.module';
export const createKyselyClient = <DB>(options: KyselyConfig): Kysely<DB> => {
return new Kysely<DB>(options);
};
export const createKyselyProvider = (options: KyselyConfig): Provider => ({
provide: KYSELY_CLIENT_PROVIDER_TOKEN,
useValue: createKyselyClient(options),
});

View File

@ -0,0 +1,530 @@
import type { ColumnType } from 'kysely';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export const CostTypeVariant = {
NACHTZUSCHLAG: 'NACHTZUSCHLAG',
WOCHENENDZUSCHLAG: 'WOCHENENDZUSCHLAG',
FEIERTAGSZUSCHLAG: 'FEIERTAGSZUSCHLAG',
DESINFEKTIONSZUSCHLAG: 'DESINFEKTIONSZUSCHLAG',
KILOMETERZUSCHLAG: 'KILOMETERZUSCHLAG',
FAHRZEUGZUSCHLAG: 'FAHRZEUGZUSCHLAG',
TSWDIFFERENZIERUNG: 'TSWDIFFERENZIERUNG',
GRUNDPREIS: 'GRUNDPREIS',
} as const;
export type CostTypeVariant =
(typeof CostTypeVariant)[keyof typeof CostTypeVariant];
export const TicketState = {
CREATED: 'CREATED',
APPROVAL_REQUIRED: 'APPROVAL_REQUIRED',
READY: 'READY',
DONE: 'DONE',
BILLABLE: 'BILLABLE',
ARCHIVED: 'ARCHIVED',
} as const;
export type TicketState = (typeof TicketState)[keyof typeof TicketState];
export const TicketValidationState = {
CREATED: 'CREATED',
USER_INTERACTION_REQUIRED: 'USER_INTERACTION_REQUIRED',
TD_UNCERTAIN: 'TD_UNCERTAIN',
DOCUMENTS_MISSING: 'DOCUMENTS_MISSING',
ARCHIVED: 'ARCHIVED',
} as const;
export type TicketValidationState =
(typeof TicketValidationState)[keyof typeof TicketValidationState];
export const ApprovalState = {
UNKNOWN: 'UNKNOWN',
FREE: 'FREE',
REQUIRED: 'REQUIRED',
REQUESTED: 'REQUESTED',
APPROVED: 'APPROVED',
} as const;
export type ApprovalState = (typeof ApprovalState)[keyof typeof ApprovalState];
export type Anomaly = {
id: string;
type: string;
description: string;
groupKey: string;
groupDescription: string;
workedOnBy: string;
sleepTimer: number;
sleepSince: Timestamp | null;
resolvedAt: Timestamp | null;
manuallyResolved: Generated<boolean>;
createdAt: Generated<Timestamp>;
};
export type AttendanceRegistration = {
attendanceRegistrationId: number;
employeeId: number;
personnelNumber: string;
dateTime: Timestamp;
event: string;
};
export type AttendanceRegistrationSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
attendanceRegistrationId: string | null;
employeeId: string | null;
personnelNumber: string | null;
dateTime: string | null;
event: string | null;
};
export type AttendanceRegistrationStaging = {
internalId: Generated<string>;
attendanceRegistrationId: number;
employeeId: number;
dateTime: Timestamp;
event: string;
};
export type AvicennaTenant = {
id: Generated<string>;
healthInsuranceNumber: string;
name: string;
};
export type AvicennaTenantToTariffGroup = {
A: string;
B: string;
};
export type CostType = {
id: Generated<string>;
description: string;
variant: CostTypeVariant;
costAmount: number;
timeRange: string | null;
kmInclusive: number | null;
excludeCarNames: string[];
infectionType: string | null;
tariffId: string | null;
};
export type DyflexisEmployee = {
id: Generated<string>;
userId: string;
firstname: string;
surname: string;
contractStart: string;
contractEnd: string | null;
contractHoursWeek: number;
contractSalaryHour: number;
};
export type Employee = {
id: string;
name: string;
surname: string;
personnelNumber: string | null;
mobile: string | null;
qualification: string | null;
occuptationalHealthExamination: Timestamp | null;
personTransportCertificate: Timestamp | null;
};
export type EmployeeDispoLiveSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
pScheinAblaufDatum: string | null;
name: string | null;
surname: string | null;
persId: string | null;
importPersId: string | null;
qualifikation: string | null;
};
export type EmployeeDyflexisSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
firstname: string | null;
surname: string | null;
contract_id: string | null;
contract_start: string | null;
contract_end: string | null;
contract_hours_week: string | null;
contract_salary_hour: string | null;
};
export type EmployeeStaging = {
internalId: Generated<string>;
id: number;
firstname: string;
surname: string;
contractStart: Timestamp;
contractEnd: Timestamp;
contractHoursWeek: number;
contractSalaryHour: number;
personTransportCertificate: Timestamp | null;
qualification: string | null;
};
export type Patient = {
id: string;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
};
export type PatientSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: string | null;
kkId: string | null;
krankenkasse: string | null;
pflegestufe: string | null;
behinderung1: string | null;
behinderung2: string | null;
};
export type PatientStaging = {
internalId: Generated<string>;
id: string | null;
surname: string | null;
name: string | null;
street: string | null;
zip: string | null;
city: string | null;
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
};
export type PlannedTime = {
id: string;
userId: string;
firstname: string | null;
surname: string | null;
startDate: Timestamp | null;
endDate: Timestamp | null;
department: string | null;
pause: number;
duration: number;
note: string | null;
};
export type PlannedTimeSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
user_id: string | null;
firstname: string | null;
surname: string | null;
department_id: string | null;
department_name: string | null;
start_date: string | null;
end_date: string | null;
pauze: string | null;
duration: string | null;
deleted: string | null;
mark: string | null;
};
export type PlannedTimeStaging = {
internalId: Generated<string>;
id: number;
employeeId: number;
firstname: string | null;
surname: string | null;
departmentName: string | null;
startDate: Timestamp;
endDate: Timestamp;
deleted: boolean;
mark: string | null;
};
export type SolutionTarget = {
id: Generated<string>;
name: string;
anomalyId: string | null;
};
export type SolutionTargetStep = {
id: Generated<string>;
description: string;
solutionTargetId: string | null;
};
export type Tariff = {
id: Generated<string>;
validFrom: Timestamp | null;
validTo: Timestamp | null;
tariffGroupId: string | null;
};
export type TariffGroup = {
id: Generated<string>;
description: string;
ordinanceType: string;
};
export type Ticket = {
id: Generated<string>;
currentState: TicketValidationState;
notes: string[];
errors: string[];
isUrgent: Generated<boolean>;
urgency: Generated<Timestamp>;
approvalState: ApprovalState | null;
documentInfo: Generated<unknown | null>;
createdAt: Generated<Timestamp>;
};
export type TicketStateHistory = {
id: Generated<string>;
state: TicketValidationState;
createdAt: Generated<Timestamp>;
ticketId: string;
};
export type Tour = {
id: Generated<string>;
done: boolean;
direction: string;
carName: string | null;
operationId: string;
driverId: string | null;
driverName: string | null;
codriverId: string | null;
codriverName: string | null;
patientId: string | null;
patientName: string | null;
patientSurname: string | null;
patientStreet: string | null;
patientZip: string | null;
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
type: string | null;
category: string | null;
transportType: string | null;
ordinanceType: string | null;
rangeEndDate: Timestamp | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
startDate: Timestamp;
check: Generated<number>;
occupiedKm: number | null;
totalKm: number | null;
startBegin: string | null;
startEnd: string | null;
target: string | null;
targetBegin: string | null;
empty: string | null;
hasInfection: Generated<boolean>;
infectionName: string | null;
revenueDispoLive: number | null;
billDate: Timestamp | null;
billNumber: string | null;
revenue: Generated<number>;
consumptionCosts: Generated<number>;
revenueDeviation: Generated<number>;
createdAt: Timestamp | null;
deletedAt: Timestamp | null;
ticketId: string | null;
};
export type TourSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
check: string | null;
createTime: string | null;
empfangenVonId: string | null;
endgen: string | null;
serienId: string | null;
serienKategorName: string | null;
serienKategorie: string | null;
zadStatus: string | null;
abrDate: string | null;
abrNum: string | null;
abrTime: string | null;
ausrueckZeit: string | null;
startTime: string | null;
startDate: string | null;
date: string | null;
date_iso: string | null;
dauergenehmigung: string | null;
direction: string | null;
distanceValue: string | null;
besetztKm: string | null;
gefahren: string | null;
gesamtPreis: string | null;
infektion: string | null;
itDesc: string | null;
itKrz: string | null;
itName: string | null;
kkId: string | null;
krankenkasse: string | null;
patId: string | null;
patName: string | null;
patSurname: string | null;
patStreet: string | null;
patZip: string | null;
patCity: string | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
transportKrz: string | null;
transportName: string | null;
transportart: string | null;
verordnungsId: string | null;
verordnungsName: string | null;
type: string | null;
startBegin: string | null;
startEnd: string | null;
frei: string | null;
ziel: string | null;
zielBegin: string | null;
endTime: string | null;
endDate: string | null;
einsatzNummer: string | null;
carName: string | null;
carNo: string | null;
beifahrerName: string | null;
beifahrer: string | null;
fahrerName: string | null;
fahrer: string | null;
color: string | null;
};
export type TourStaging = {
internalId: Generated<string>;
id: string | null;
done: boolean | null;
direction: string | null;
carName: string | null;
operationId: string | null;
driverId: string | null;
driverName: string | null;
codriverId: string | null;
codriverName: string | null;
patientId: string | null;
patientName: string | null;
patientSurname: string | null;
patientStreet: string | null;
patientZip: string | null;
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
type: string | null;
category: string | null;
transportType: string | null;
ordinanceType: string | null;
rangeEndDate: Timestamp | null;
startInstitution: string | null;
startStreet: string | null;
startZip: string | null;
startCity: string | null;
targetInstitution: string | null;
targetStreet: string | null;
targetZip: string | null;
targetCity: string | null;
startDate: Timestamp | null;
check: Generated<number>;
occupiedKm: number | null;
totalKm: number | null;
startBegin: string | null;
startEnd: string | null;
target: string | null;
targetBegin: string | null;
empty: string | null;
hasInfection: Generated<boolean>;
infectionName: string | null;
revenueDispoLive: number | null;
billDate: Timestamp | null;
billNumber: string | null;
revenue: Generated<number>;
revenueDeviation: Generated<number>;
consumptionCosts: Generated<number>;
createdAt: Timestamp | null;
};
export type Trace = {
id: Generated<string>;
type: string;
entityKey: string;
createdAt: Generated<Timestamp>;
};
export type WorkTime = {
id: string;
userId: string;
firstname: string;
surname: string;
startDate: Timestamp | null;
endDate: Timestamp | null;
department: string | null;
pause: number;
duration: number;
};
export type WorkTimeSource = {
internalId: Generated<string>;
insertedAt: Generated<Timestamp>;
jobId: string;
id: string | null;
user_id: string | null;
firstname: string | null;
surname: string | null;
department_id: string | null;
department_name: string | null;
start_date: string | null;
end_date: string | null;
pauze: string | null;
duration: string | null;
};
export type WorkTimeStaging = {
internalId: Generated<string>;
id: number;
employeeId: number;
firstname: string;
surname: string;
departmentName: string | null;
startDate: Timestamp;
endDate: Timestamp;
};
export type DB = {
_AvicennaTenantToTariffGroup: AvicennaTenantToTariffGroup;
Anomaly: Anomaly;
AttendanceRegistration: AttendanceRegistration;
AttendanceRegistrationSource: AttendanceRegistrationSource;
AttendanceRegistrationStaging: AttendanceRegistrationStaging;
AvicennaTenant: AvicennaTenant;
CostType: CostType;
DyflexisEmployee: DyflexisEmployee;
Employee: Employee;
EmployeeDispoLiveSource: EmployeeDispoLiveSource;
EmployeeDyflexisSource: EmployeeDyflexisSource;
EmployeeStaging: EmployeeStaging;
Patient: Patient;
PatientSource: PatientSource;
PatientStaging: PatientStaging;
PlannedTime: PlannedTime;
PlannedTimeSource: PlannedTimeSource;
PlannedTimeStaging: PlannedTimeStaging;
SolutionTarget: SolutionTarget;
SolutionTargetStep: SolutionTargetStep;
Tariff: Tariff;
TariffGroup: TariffGroup;
Ticket: Ticket;
TicketStateHistory: TicketStateHistory;
Tour: Tour;
TourSource: TourSource;
TourStaging: TourStaging;
Trace: Trace;
WorkTime: WorkTime;
WorkTimeSource: WorkTimeSource;
WorkTimeStaging: WorkTimeStaging;
};

View File

@ -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[]>,
);
}
}

View File

@ -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 {}

View File

@ -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,
};
}
}

View File

@ -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 } });
}
}

View File

@ -0,0 +1,7 @@
export enum AnomalyTypes {
DriverCodriverSame = 'DriverCodriverSame',
DriverCertificate = 'DriverCertificate',
EmployeesLoggedIn = 'EmployeesLoggedIn',
OneTourAtATime = 'OneTourAtATime',
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
}

View File

@ -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()
);
}
}

View File

@ -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 }),
};
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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),
};
}
}

View File

@ -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,
},
},
},
};

View File

@ -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)];
}
}

View File

@ -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',
};

View File

@ -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;
}
}

View File

@ -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',
];

View File

@ -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 {}

View File

@ -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()),

View File

@ -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[]> {

View File

@ -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) => {

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,7 @@
export enum TicketSystemEventTypes {
Validated = 'ticket-system.validated',
}
export class TicketSystemValidatedEvent {
constructor(public readonly count: number) {}
}

View File

@ -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 {}

View File

@ -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: [],
};
}

View File

@ -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: {},
},
},
});
}
}

View File

@ -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));
}
}

View File

@ -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,
};
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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: {

View File

@ -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();
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,84 @@
import {
Interval,
addDays,
clamp,
differenceInMilliseconds,
set,
} from 'date-fns';
import { fromZonedTime } from 'date-fns-tz';
/**
* Generates an interval for the given time range for the baseDate, today by default. If the second time is lower than the first, the interval spans over to the next day
* @param timeRange A user readable time range formatted as "HH:mm-HH:mm"
*/
export function generateIntervalForTimeRange(
timeRange: string,
timeZone = 'Europe/London',
baseDate = new Date(),
): Interval {
const [firstTime, secondTime] = timeRange.split('-');
if (
!firstTime ||
!secondTime ||
firstTime?.indexOf(':') === -1 ||
secondTime?.indexOf(':') === -1
) {
throw new Error(
`Invalid argument for 'timeRange', expected format 'HH:mm-HH:mm', got ${timeRange}`,
);
}
const [startHours, startMinutes] = firstTime.split(':').map((val) => +val);
const [endHours, endMinutes] = secondTime.split(':').map((val) => +val);
const startDate = baseDate;
let endDate = baseDate;
if (endHours < startHours) {
endDate = addDays(endDate, 1);
}
return {
start: fromZonedTime(
set(startDate, {
hours: startHours,
minutes: startMinutes,
seconds: 0,
milliseconds: 0,
}),
timeZone,
),
end: fromZonedTime(
set(endDate, {
hours: endHours,
minutes: endMinutes,
seconds: 0,
milliseconds: 0,
}),
timeZone,
),
};
}
export function clampInterval(
interval: Interval,
intervalClamp: Interval,
): Interval {
return {
start: clamp(interval.start, intervalClamp),
end: clamp(interval.end, intervalClamp),
};
}
export function getHoursInInterval(
interval: Interval,
intervalClamp?: Interval,
): number {
const clampedInterval: Interval = clampInterval(
interval,
intervalClamp ?? interval,
);
return (
differenceInMilliseconds(clampedInterval.end, clampedInterval.start) /
60 /
60 /
1000
);
}

View File

@ -1,3 +1,4 @@
export * from './chunk';
export * from './helper';
export * from './is-valid-date';
export * from './range';