commit open changes from previous iterations

This commit is contained in:
Marcel Arndt 2025-11-18 14:11:27 +01:00
parent 4cf79f3094
commit c33ab89354
63 changed files with 1646 additions and 227 deletions

2
TODO.md Normal file
View File

@ -0,0 +1,2 @@
- [ ] Anomalie "Anwesenheit vor Arbeitszeit" abschliessen
- [ ] Add Dyflexis User Access Endpoints to ETL

View File

@ -10,7 +10,7 @@
<ul class="ml-4"> <ul class="ml-4">
@for (step of target.steps; track step) { @for (step of target.steps; track step) {
<li class="list-disc"> <li class="list-disc">
{{ step.description }} <div [innerHTML]="step.description"></div>
</li> </li>
} }
</ul> </ul>

View File

@ -25,7 +25,7 @@ import { Component } from '@angular/core';
class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col" class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col"
> >
<mat-card-header> <mat-card-header>
<mat-card-title> Anomalien </mat-card-title> <mat-card-title> Anomalien in der Dokumentation</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content class="flex-grow"> <mat-card-content class="flex-grow">
<dks-anomaly-tree></dks-anomaly-tree> <dks-anomaly-tree></dks-anomaly-tree>
@ -36,7 +36,7 @@ import { Component } from '@angular/core';
class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col" class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col"
> >
<mat-card-header> <mat-card-header>
<mat-card-title> Mitarbeiter ohne Tour </mat-card-title> <mat-card-title> Anomalien in der Zeiterfassung </mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content class="flex-grow"> <mat-card-content class="flex-grow">
<dks-anomaly-list></dks-anomaly-list> <dks-anomaly-list></dks-anomaly-list>

View File

@ -12,6 +12,16 @@ body {
color: var(--mat-sys-on-surface); color: var(--mat-sys-on-surface);
} }
.dispolive-btn, .dyflexis-btn {
a[target="_blank"]::after {
content: " \2924"; /* Unicode-Pfeil */
font-size: 0.8em;
margin-left: 0.2em;
vertical-align: super;
opacity: 0.8;
}
}
.pulse { .pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite; animation: pulse 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
animation-delay: 0.5s; animation-delay: 0.5s;

View File

@ -0,0 +1,31 @@
tmp/
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Editor/IDE
# .idea/
# .vscode/

View File

@ -1,4 +1,5 @@
.local/ .local/
daten/
# compiled output # compiled output
/dist /dist

View File

@ -0,0 +1,16 @@
/*
Warnings:
- Added the required column `duration` to the `PlannedTimeStaging` table without a default value. This is not possible if the table is not empty.
- Added the required column `pause` to the `PlannedTimeStaging` table without a default value. This is not possible if the table is not empty.
- Added the required column `duration` to the `WorkTimeStaging` table without a default value. This is not possible if the table is not empty.
- Added the required column `pause` to the `WorkTimeStaging` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "PlannedTimeStaging" ADD COLUMN "duration" INTEGER NOT NULL,
ADD COLUMN "pause" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "WorkTimeStaging" ADD COLUMN "duration" INTEGER NOT NULL,
ADD COLUMN "pause" INTEGER NOT NULL;

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "DyflexisEmployee" ADD COLUMN "contractTypeName" TEXT,
ALTER COLUMN "contractSalaryHour" SET DATA TYPE DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "EmployeeDyflexisSource" ADD COLUMN "contract_type_name" TEXT;
-- AlterTable
ALTER TABLE "EmployeeDyflexisStaging" ADD COLUMN "contractTypeName" TEXT;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- The primary key for the `DyflexisEmployee` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- AlterTable
ALTER TABLE "DyflexisEmployee" DROP CONSTRAINT "DyflexisEmployee_pkey",
ADD COLUMN "internalId" UUID NOT NULL DEFAULT gen_random_uuid(),
ADD CONSTRAINT "DyflexisEmployee_pkey" PRIMARY KEY ("internalId");

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "AttendanceRegistration" ALTER COLUMN "personnelNumber" DROP NOT NULL;
-- AlterTable
ALTER TABLE "AttendanceRegistrationStaging" ADD COLUMN "personnelNumber" TEXT DEFAULT '';

View File

@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "companyName" TEXT,
ADD COLUMN "overRTW" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "schwerlast" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TourSource" ADD COLUMN "companyName" TEXT,
ADD COLUMN "overRTW" TEXT,
ADD COLUMN "schwerlast" TEXT;
-- AlterTable
ALTER TABLE "TourStaging" ADD COLUMN "companyName" TEXT,
ADD COLUMN "overRTW" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "schwerlast" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Sickdays" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"start" TIMESTAMP(3) NOT NULL,
"end" TIMESTAMP(3) NOT NULL,
"hours" DOUBLE PRECISION NOT NULL,
"days" INTEGER NOT NULL,
"note" TEXT,
"locked" BOOLEAN NOT NULL,
"employeeId" INTEGER NOT NULL,
CONSTRAINT "Sickdays_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Holiday" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"date" TIMESTAMP(3) NOT NULL,
"locked" BOOLEAN NOT NULL,
"excludedFromPayroll" BOOLEAN NOT NULL,
"employeeId" INTEGER NOT NULL,
CONSTRAINT "Holiday_pkey" PRIMARY KEY ("id")
);

View File

@ -97,6 +97,10 @@ model TourSource {
fahrer String? // ID fahrer String? // ID
color String? // Hex rgb color String? // Hex rgb
overRTW String?
companyName String?
schwerlast String?
} }
model EmployeeDispoLiveSource { model EmployeeDispoLiveSource {
@ -174,6 +178,7 @@ model EmployeeDyflexisSource {
contract_end String? // yyyy-MM-dd contract_end String? // yyyy-MM-dd
contract_hours_week String? // Float string contract_hours_week String? // Float string
contract_salary_hour String? // Float string contract_salary_hour String? // Float string
contract_type_name String?
} }
model PatientSource { model PatientSource {
@ -256,6 +261,10 @@ model TourStaging {
revenueDeviation Float @default(0) revenueDeviation Float @default(0)
consumptionCosts Float @default(0) consumptionCosts Float @default(0)
overRTW Boolean @default(false)
companyName String?
schwerlast Boolean @default(false)
createdAt DateTime? createdAt DateTime?
updatedAt DateTime? updatedAt DateTime?
} }
@ -285,6 +294,7 @@ model AttendanceRegistrationStaging {
employeeId Int employeeId Int
dateTime DateTime dateTime DateTime
event String event String
personnelNumber String? @default("")
} }
model PlannedTimeStaging { model PlannedTimeStaging {
@ -299,6 +309,8 @@ model PlannedTimeStaging {
endDate DateTime endDate DateTime
deleted Boolean deleted Boolean
mark String? mark String?
pause Int
duration Int
} }
model WorkTimeStaging { model WorkTimeStaging {
@ -311,6 +323,8 @@ model WorkTimeStaging {
departmentName String? departmentName String?
startDate DateTime startDate DateTime
endDate DateTime endDate DateTime
pause Int
duration Int
} }
model EmployeeDyflexisStaging { model EmployeeDyflexisStaging {
@ -323,6 +337,7 @@ model EmployeeDyflexisStaging {
contractEnd DateTime contractEnd DateTime
contractHoursWeek Float contractHoursWeek Float
contractSalaryHour Float contractSalaryHour Float
contractTypeName String?
} }
model EmployeeDispoLiveStaging { model EmployeeDispoLiveStaging {
@ -395,6 +410,10 @@ model Tour {
consumptionCosts Float @default(0) consumptionCosts Float @default(0)
revenueDeviation Float @default(0) revenueDeviation Float @default(0)
overRTW Boolean @default(false)
companyName String?
schwerlast Boolean @default(false)
createdAt DateTime? createdAt DateTime?
updatedAt DateTime? updatedAt DateTime?
deletedAt DateTime? deletedAt DateTime?
@ -469,7 +488,7 @@ model TicketStateHistory {
model AttendanceRegistration { model AttendanceRegistration {
attendanceRegistrationId Int @id attendanceRegistrationId Int @id
employeeId Int employeeId Int
personnelNumber String personnelNumber String?
dateTime DateTime dateTime DateTime
event String event String
} }
@ -511,13 +530,36 @@ model Employee {
} }
model DyflexisEmployee { model DyflexisEmployee {
id Int @id internalId String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
id Int
firstname String firstname String
surname String surname String
contractStart DateTime contractStart DateTime
contractEnd DateTime? contractEnd DateTime?
contractHoursWeek Float contractHoursWeek Float
contractSalaryHour Int contractSalaryHour Float
contractTypeName String?
}
model Sickdays {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
start DateTime
end DateTime
hours Float
days Int
note String?
locked Boolean
employeeId Int
}
model Holiday {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
date DateTime
locked Boolean
excludedFromPayroll Boolean
employeeId Int
} }
enum CostTypeVariant { enum CostTypeVariant {

View File

@ -179,7 +179,7 @@ export const KTW_AOK_TARIFFS = [
{ {
description: 'AOK/BKK/IKK/Knappschaft KTW', description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2024-01-01')), validFrom: startOfDay(new Date('2024-01-01')),
validTo: endOfDay(new Date('2024-12-31')), validTo: endOfDay(new Date('2024-10-31')),
costTypes: [ costTypes: [
{ {
description: 'Grundpauschale je Fahrt', description: 'Grundpauschale je Fahrt',
@ -200,7 +200,7 @@ export const KTW_AOK_TARIFFS = [
'1/KTW-02', '1/KTW-02',
'1/KTW-03', '1/KTW-03',
'1/KTW-04', '1/KTW-04',
'1/KTW-05-E', '1/KTW-05',
'1/KTW-06', '1/KTW-06',
], ],
}, },
@ -262,6 +262,84 @@ export const KTW_AOK_TARIFFS = [
}, },
], ],
}, },
{
description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2024-11-01')),
validTo: endOfDay(new Date('2024-12-31')),
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
},
{
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 18:30-06:30',
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',
},
],
},
{ {
description: 'AOK/BKK/IKK/Knappschaft KTW', description: 'AOK/BKK/IKK/Knappschaft KTW',
validFrom: startOfDay(new Date('2025-01-01')), validFrom: startOfDay(new Date('2025-01-01')),

View File

@ -13,10 +13,4 @@ describe('AppController', () => {
appController = app.get<AppController>(AppController); appController = app.get<AppController>(AppController);
}); });
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
}); });

View File

@ -65,6 +65,7 @@ export class AppController {
@Get('updated-tours') @Get('updated-tours')
async updatedTours(@Query('from') from: string, @Query('to') to: string) { async updatedTours(@Query('from') from: string, @Query('to') to: string) {
this.logger.log(`Fetch Tour updates in interval from=${from} to=${to}`);
const interval = { const interval = {
start: new Date(from), start: new Date(from),
end: new Date(to), end: new Date(to),
@ -165,4 +166,103 @@ export class AppController {
} }
return toursWithRevenue; return toursWithRevenue;
} }
@Get('leitstellendaten')
async versandlisteLeitstellendaten(
@Query('from') from: string,
@Query('to') to: string,
) {
const interval = {
start: new Date(from),
end: new Date(to),
};
const tours = await this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
{
healthInsurance: {
not: 'KOSTENTRÄGER PATIENT',
},
},
{
ordinanceType: {
contains: 'KTW',
},
},
{
carName: {
contains: 'KTW',
},
},
{
check: {
lt: 3,
},
},
{
deletedAt: null,
},
{
OR: [
{
patientId: null,
},
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
orderBy: {
startDate: 'desc',
},
select: {
companyName: true,
createdAt: true,
startDate: true,
startBegin: true,
startEnd: true,
empty: true,
startZip: true,
targetZip: true,
category: true,
rangeEndDate: true,
hasInfection: true,
schwerlast: true,
overRTW: true,
carName: true,
operationId: true,
ordinanceType: true,
patientName: true,
patientSurname: true,
infectionName: true,
},
});
return tours;
}
@Get('dyflexis-user-access')
async loadDyflexisUserData() {
return this.importer.runDyflexisUserAccessTest();
}
} }

View File

@ -30,6 +30,15 @@ export class ConfigService extends NestConfigService {
dyflexisV2ApiKey(): string { dyflexisV2ApiKey(): string {
return this.get('DYFLEXIS_V2_API_KEY'); return this.get('DYFLEXIS_V2_API_KEY');
} }
dyflexisAppBaseUrl(): string {
return this.get('DYFLEXIS_APP_BASE_URL');
}
dyflexisAppUsername(): string {
return this.get('DYFLEXIS_APP_USERNAME');
}
dyflexisAppPassword(): string {
return this.get('DYFLEXIS_APP_PASSWORD');
}
databasePort(): string { databasePort(): string {
return this.get('DATABASE_PORT'); return this.get('DATABASE_PORT');
} }

View File

@ -60,7 +60,7 @@ export type Anomaly = {
export type AttendanceRegistration = { export type AttendanceRegistration = {
attendanceRegistrationId: number; attendanceRegistrationId: number;
employeeId: number; employeeId: number;
personnelNumber: string; personnelNumber: string | null;
dateTime: Timestamp; dateTime: Timestamp;
event: string; event: string;
}; };
@ -80,6 +80,7 @@ export type AttendanceRegistrationStaging = {
employeeId: number; employeeId: number;
dateTime: Timestamp; dateTime: Timestamp;
event: string; event: string;
personnelNumber: Generated<string | null>;
}; };
export type AvicennaTenant = { export type AvicennaTenant = {
id: Generated<string>; id: Generated<string>;
@ -102,6 +103,7 @@ export type CostType = {
tariffId: string | null; tariffId: string | null;
}; };
export type DyflexisEmployee = { export type DyflexisEmployee = {
internalId: Generated<string>;
id: number; id: number;
firstname: string; firstname: string;
surname: string; surname: string;
@ -109,6 +111,7 @@ export type DyflexisEmployee = {
contractEnd: Timestamp | null; contractEnd: Timestamp | null;
contractHoursWeek: number; contractHoursWeek: number;
contractSalaryHour: number; contractSalaryHour: number;
contractTypeName: string | null;
}; };
export type Employee = { export type Employee = {
id: string; id: string;
@ -153,6 +156,7 @@ export type EmployeeDyflexisSource = {
contract_end: string | null; contract_end: string | null;
contract_hours_week: string | null; contract_hours_week: string | null;
contract_salary_hour: string | null; contract_salary_hour: string | null;
contract_type_name: string | null;
}; };
export type EmployeeDyflexisStaging = { export type EmployeeDyflexisStaging = {
internalId: Generated<string>; internalId: Generated<string>;
@ -163,6 +167,14 @@ export type EmployeeDyflexisStaging = {
contractEnd: Timestamp; contractEnd: Timestamp;
contractHoursWeek: number; contractHoursWeek: number;
contractSalaryHour: number; contractSalaryHour: number;
contractTypeName: string | null;
};
export type Holiday = {
id: Generated<string>;
date: Timestamp;
locked: boolean;
excludedFromPayroll: boolean;
employeeId: number;
}; };
export type Patient = { export type Patient = {
id: string; id: string;
@ -250,6 +262,18 @@ export type PlannedTimeStaging = {
endDate: Timestamp; endDate: Timestamp;
deleted: boolean; deleted: boolean;
mark: string | null; mark: string | null;
pause: number;
duration: number;
};
export type Sickdays = {
id: Generated<string>;
start: Timestamp;
end: Timestamp;
hours: number;
days: number;
note: string | null;
locked: boolean;
employeeId: number;
}; };
export type SolutionTarget = { export type SolutionTarget = {
id: Generated<string>; id: Generated<string>;
@ -337,11 +361,25 @@ export type Tour = {
revenue: Generated<number>; revenue: Generated<number>;
consumptionCosts: Generated<number>; consumptionCosts: Generated<number>;
revenueDeviation: Generated<number>; revenueDeviation: Generated<number>;
overRTW: Generated<boolean>;
companyName: string | null;
schwerlast: Generated<boolean>;
createdAt: Timestamp | null; createdAt: Timestamp | null;
updatedAt: Timestamp | null; updatedAt: Timestamp | null;
deletedAt: Timestamp | null; deletedAt: Timestamp | null;
ticketId: string | null; ticketId: string | null;
}; };
export type TourFile = {
id: Generated<string>;
tourId: string;
ticketId: string;
storagePath: string;
originalFilename: string;
mimeType: string;
fileSizeBytes: number;
dlUploadStatus: Generated<string>;
createdAt: Generated<Timestamp | null>;
};
export type TourSource = { export type TourSource = {
internalId: Generated<string>; internalId: Generated<string>;
insertedAt: Generated<Timestamp>; insertedAt: Generated<Timestamp>;
@ -411,6 +449,9 @@ export type TourSource = {
fahrerName: string | null; fahrerName: string | null;
fahrer: string | null; fahrer: string | null;
color: string | null; color: string | null;
overRTW: string | null;
companyName: string | null;
schwerlast: string | null;
}; };
export type TourStaging = { export type TourStaging = {
internalId: Generated<string>; internalId: Generated<string>;
@ -461,6 +502,9 @@ export type TourStaging = {
revenue: Generated<number>; revenue: Generated<number>;
revenueDeviation: Generated<number>; revenueDeviation: Generated<number>;
consumptionCosts: Generated<number>; consumptionCosts: Generated<number>;
overRTW: Generated<boolean>;
companyName: string | null;
schwerlast: Generated<boolean>;
createdAt: Timestamp | null; createdAt: Timestamp | null;
updatedAt: Timestamp | null; updatedAt: Timestamp | null;
}; };
@ -505,6 +549,8 @@ export type WorkTimeStaging = {
departmentName: string | null; departmentName: string | null;
startDate: Timestamp; startDate: Timestamp;
endDate: Timestamp; endDate: Timestamp;
pause: number;
duration: number;
}; };
export type DB = { export type DB = {
_AvicennaTenantToTariffGroup: AvicennaTenantToTariffGroup; _AvicennaTenantToTariffGroup: AvicennaTenantToTariffGroup;
@ -520,12 +566,14 @@ export type DB = {
EmployeeDispoLiveStaging: EmployeeDispoLiveStaging; EmployeeDispoLiveStaging: EmployeeDispoLiveStaging;
EmployeeDyflexisSource: EmployeeDyflexisSource; EmployeeDyflexisSource: EmployeeDyflexisSource;
EmployeeDyflexisStaging: EmployeeDyflexisStaging; EmployeeDyflexisStaging: EmployeeDyflexisStaging;
Holiday: Holiday;
Patient: Patient; Patient: Patient;
PatientSource: PatientSource; PatientSource: PatientSource;
PatientStaging: PatientStaging; PatientStaging: PatientStaging;
PlannedTime: PlannedTime; PlannedTime: PlannedTime;
PlannedTimeSource: PlannedTimeSource; PlannedTimeSource: PlannedTimeSource;
PlannedTimeStaging: PlannedTimeStaging; PlannedTimeStaging: PlannedTimeStaging;
Sickdays: Sickdays;
SolutionTarget: SolutionTarget; SolutionTarget: SolutionTarget;
SolutionTargetStep: SolutionTargetStep; SolutionTargetStep: SolutionTargetStep;
Tariff: Tariff; Tariff: Tariff;
@ -533,6 +581,7 @@ export type DB = {
Ticket: Ticket; Ticket: Ticket;
TicketStateHistory: TicketStateHistory; TicketStateHistory: TicketStateHistory;
Tour: Tour; Tour: Tour;
TourFile: TourFile;
TourSource: TourSource; TourSource: TourSource;
TourStaging: TourStaging; TourStaging: TourStaging;
Trace: Trace; Trace: Trace;

View File

@ -3,6 +3,9 @@ import { Tour } from '@prisma/client';
@ObjectType('Tour') @ObjectType('Tour')
export class TourObjectType implements Tour { export class TourObjectType implements Tour {
overRTW: boolean;
companyName: string;
schwerlast: boolean;
@Field() @Field()
id: string; id: string;
@Field() @Field()

View File

@ -52,7 +52,11 @@ export class ControlCenterResolver {
const activeAnomalies = await this.anomalyService.findAllVisible( const activeAnomalies = await this.anomalyService.findAllVisible(
skip, skip,
take, take,
[AnomalyTypes.WorkingEmployeeNotInDistributionSystem], [
AnomalyTypes.WorkingEmployeeNotInDistributionSystem,
AnomalyTypes.EmployeeStillWorking,
AnomalyTypes.IsAttendanceBeforeWorktime,
],
); );
const totalCount = await this.anomalyService.countVisibleAnomalies([ const totalCount = await this.anomalyService.countVisibleAnomalies([
@ -134,6 +138,7 @@ export class ControlCenterResolver {
AnomalyTypes.DriverCertificate, AnomalyTypes.DriverCertificate,
AnomalyTypes.EmployeesLoggedIn, AnomalyTypes.EmployeesLoggedIn,
AnomalyTypes.OneTourAtATime, AnomalyTypes.OneTourAtATime,
AnomalyTypes.IsGermanZip,
], ],
); );

View File

@ -10,6 +10,10 @@ import { AnomalyCreateDto } from '../feat-business-objects/entities/anomaly/anom
import { AnomalyService } from '../feat-business-objects/entities/anomaly/anomaly.service'; import { AnomalyService } from '../feat-business-objects/entities/anomaly/anomaly.service';
import { AnomalyDetector } from './anomaly-detectors/anomaly-detector.class'; import { AnomalyDetector } from './anomaly-detectors/anomaly-detector.class';
import { WorkingEmployeeNotInDistributionSystemDetector } from './anomaly-detectors/working-employee-not-in-distribution-system.detector'; import { WorkingEmployeeNotInDistributionSystemDetector } from './anomaly-detectors/working-employee-not-in-distribution-system.detector';
import { IsGermanZipDetector } from './anomaly-detectors/is-german-zip.detector';
import { EmployeeStillWorkingDetector } from './anomaly-detectors/employee-still-working.detector';
import { IsAttendanceBeforeWorktimeDetector } from './anomaly-detectors/is-attendance-before-worktime.detector';
import { IsWorkTimePlannedDetector } from './anomaly-detectors/is-work-time-planned.detector';
@Injectable() @Injectable()
export class AnomaliesService { export class AnomaliesService {
@ -27,6 +31,10 @@ export class AnomaliesService {
new WorkingEmployeeNotInDistributionSystemDetector( new WorkingEmployeeNotInDistributionSystemDetector(
this.anomalyDetectorService, this.anomalyDetectorService,
), ),
new IsGermanZipDetector(this.anomalyDetectorService),
new EmployeeStillWorkingDetector(this.anomalyDetectorService),
new IsAttendanceBeforeWorktimeDetector(this.anomalyDetectorService),
// new IsWorkTimePlannedDetector(this.anomalyDetectorService)
]; ];
} }
@ -53,11 +61,13 @@ export class AnomaliesService {
const anomalies = await this.anomalyService.findAllUnsolved(); const anomalies = await this.anomalyService.findAllUnsolved();
return Promise.all( return Promise.all(
anomalies.map((anomaly) => anomalies
.map((anomaly) =>
this.detectors this.detectors
.find((detector) => detector.type === anomaly.type) .find((detector) => detector.type === anomaly.type)
.validate(anomaly), ?.validate(anomaly),
), )
.filter(Boolean),
).then(async (anomalies) => { ).then(async (anomalies) => {
const validatedAnomalies = anomalies.flat(); const validatedAnomalies = anomalies.flat();
const updatedAnomalies: Anomaly[] = []; const updatedAnomalies: Anomaly[] = [];

View File

@ -3,10 +3,11 @@ import { PrismaModule } from 'src/core/database/prisma.module';
import { AnomaliesService } from './anomalies.service'; import { AnomaliesService } from './anomalies.service';
import { AnomalyDetectorService } from './anomaly-detectors/anomaly-detector.service'; import { AnomalyDetectorService } from './anomaly-detectors/anomaly-detector.service';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module'; import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { AnomalyDetectorRepository } from './anomaly-detectors/anomaly-detector.repository';
@Module({ @Module({
imports: [PrismaModule, FeatBusinessObjectsModule], imports: [PrismaModule, FeatBusinessObjectsModule],
providers: [AnomaliesService, AnomalyDetectorService], providers: [AnomaliesService, AnomalyDetectorService, AnomalyDetectorRepository],
exports: [AnomaliesService], exports: [AnomaliesService],
}) })
export class AnomalyDetectionModule {} export class AnomalyDetectionModule {}

View File

@ -0,0 +1,255 @@
import { Inject, Injectable } from '@nestjs/common';
import { AttendanceRegistration, Tour } from '@prisma/client';
import { addDays, endOfDay, Interval, startOfDay, subDays } from 'date-fns';
import { ExpressionBuilder, Kysely, sql } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
type TourEb = ExpressionBuilder<DB, 'Tour'>;
const PAUSE_PATIENT_ID = 'b7424477-a4b2-42f7-b7e7-a4bef24f6979';
const FEIERABEND_PATIENT_ID = '3554e1a9-9c07-45e5-b53c-692a77e4fa9a';
function onlyRealTours(eb: TourEb) {
return eb.and([
eb.or([
eb('carName', 'is', null),
eb('carName', 'not in', ['Storno', 'Löschen', 'Abgabe']),
]),
eb('check', '!=', 3),
eb('deletedAt', 'is', null),
eb.or([
eb('patientId', 'is', null),
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
]),
]);
}
@Injectable()
export class AnomalyDetectorRepository {
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async toursWithoutGermanZipInInterval(interval: Interval): Promise<Tour[]> {
return await this.db
.selectFrom('Tour')
.selectAll()
.where((eb) =>
eb.and([
eb.or([
eb(sql<number>`LENGTH("startZip")`, '<', 5),
eb(sql<number>`LENGTH("targetZip")`, '<', 5),
]),
eb('Tour.startDate', '>=', new Date(interval.start)),
eb('Tour.startDate', '<=', new Date(interval.end)),
onlyRealTours(eb),
]),
)
.execute();
}
async employeeStillWorking(
date: Date,
): Promise<{ employeeId: number; checkIn: Date; checkOut: Date }[]> {
const eventWithNextCte = this.db.with('EventWithNext', (db) =>
db
.selectFrom('AttendanceRegistration')
.select([
'employeeId',
'dateTime',
'event',
sql<string>`
LEAD("event") OVER (
PARTITION BY "employeeId"
ORDER BY "dateTime" ASC
)
`.as('nextEvent'),
])
.where('dateTime', '>=', subDays(date, 1))
.where('dateTime', '<=', addDays(date, 2)),
);
const filteredEventsCte = eventWithNextCte.with('FilteredEvents', (db) =>
db
.selectFrom('EventWithNext')
.select(['employeeId', 'dateTime', 'event'])
.where((eb) =>
eb.or([
eb('event', '=', 'clocked out'),
eb.and([
eb('event', '=', 'clocked in'),
eb.or([
eb('nextEvent', 'is', null),
eb('nextEvent', '!=', 'clocked in'),
]),
]),
]),
),
);
const pairedEventsCte = filteredEventsCte.with('PairedEvents', (db) =>
db
.selectFrom('FilteredEvents')
.select([
'employeeId',
'dateTime',
'event',
sql<Date>`LEAD("dateTime") OVER (PARTITION BY "employeeId" ORDER BY "dateTime" ASC)`.as(
'nextDateTime',
),
sql<string>`LEAD("event") OVER (PARTITION BY "employeeId" ORDER BY "dateTime" ASC)`.as(
'nextEvent',
),
]),
);
return await pairedEventsCte
.selectFrom('PairedEvents')
.select(['employeeId', 'dateTime as checkIn', 'nextDateTime as checkOut'])
.where('event', '=', 'clocked in')
.where('dateTime', '>=', startOfDay(date))
.where('dateTime', '<=', endOfDay(date))
.where((eb) =>
eb.or([
eb.and([
eb('nextEvent', '=', 'clocked out'),
eb(sql`"nextDateTime" - "dateTime"`, '>', sql`interval '23 hours'`),
]),
eb.and([
eb('nextEvent', 'is', null),
eb(
sql`${new Date()}::timestamp - "dateTime"`,
'>',
sql`interval '23 hours'`,
),
]),
]),
)
.execute();
}
async attendanceBeforeWorkTime(day: Date): Promise<
{
userId: string;
checkInTime: Date;
startWorkTime: Date;
name: string;
}[]
> {
const dailyWorkTimeCte = this.db.with('DailyWorkTime', (db) =>
db
.selectFrom('WorkTime')
.select([
'WorkTime.userId',
'WorkTime.startDate as startWorkTime',
'WorkTime.firstname',
'WorkTime.surname',
])
.where('WorkTime.startDate', '>=', startOfDay(day))
.where('WorkTime.startDate', '<', endOfDay(day))
.where('WorkTime.startDate', 'is not', null),
);
const firstCheckInCte = dailyWorkTimeCte.with('FirstCheckIn', (db) =>
db
.selectFrom('AttendanceRegistration')
.select([
'AttendanceRegistration.employeeId',
(eb) =>
eb.fn.min('AttendanceRegistration.dateTime').as('checkInTime'),
])
.where('AttendanceRegistration.event', '=', 'clocked in')
.where('AttendanceRegistration.dateTime', '>=', startOfDay(day))
.where('AttendanceRegistration.dateTime', '<', endOfDay(day))
.where('AttendanceRegistration.dateTime', 'is not', null)
.groupBy('AttendanceRegistration.employeeId'),
);
return await firstCheckInCte
.selectFrom('DailyWorkTime')
.innerJoin('FirstCheckIn', (eb) =>
eb.onRef(
sql`"DailyWorkTime"."userId"::integer`,
'=',
'FirstCheckIn.employeeId',
),
)
.select([
'DailyWorkTime.userId',
'FirstCheckIn.checkInTime',
'DailyWorkTime.startWorkTime',
sql<string>`"DailyWorkTime"."firstname" || ' ' || "DailyWorkTime"."surname"`.as(
'name',
),
])
.whereRef(
'FirstCheckIn.checkInTime',
'>',
sql`"DailyWorkTime"."startWorkTime" + interval '60 seconds'`,
)
.execute();
}
async findWorkForPlannedTime(day: Date) {
const aggregatedPlannedTimeCte = this.db.with(
'AggregatedPlannedTime',
(db) =>
db
.selectFrom('PlannedTime')
.select([
'PlannedTime.userId',
sql<string>`MIN("firstname")`.as('firstname'),
sql<string>`MIN("surname")`.as('surname'),
(eb) => eb.fn.min('PlannedTime.startDate').as('plannedStart'),
(eb) => eb.fn.max('PlannedTime.endDate').as('plannedEnd'),
])
.where('PlannedTime.startDate', '>=', startOfDay(day))
.where('PlannedTime.startDate', '<', endOfDay(day))
.groupBy('PlannedTime.userId'),
);
const aggregatedWorkTimeCte = aggregatedPlannedTimeCte.with(
'AggregatedWorkTime',
(db) =>
db
.selectFrom('WorkTime')
.select([
'WorkTime.userId',
(eb) => eb.fn.min('WorkTime.startDate').as('actualStart'),
(eb) => eb.fn.max('WorkTime.endDate').as('actualEnd'),
sql<number>`SUM("pause")`.as('actualPause'),
sql<number>`SUM("duration")`.as('actualDuration'),
])
.where('WorkTime.startDate', '>=', startOfDay(day))
.where('WorkTime.startDate', '<', endOfDay(day))
.groupBy('WorkTime.userId'),
);
return await aggregatedWorkTimeCte
.selectFrom('AggregatedPlannedTime as pt')
.leftJoin('AggregatedWorkTime as wt', 'wt.userId', 'pt.userId')
.select([
'pt.userId',
'pt.firstname',
'pt.surname',
'pt.plannedStart',
'pt.plannedEnd',
'wt.actualStart',
'wt.actualEnd',
sql<boolean>`("wt"."actualEnd" - "wt"."actualStart") - ("pt"."plannedEnd" - "pt"."plannedStart")`.as(
'diff',
),
])
.where((eb) =>
eb.or([
// eb('wt.userId', 'is', null),
sql<boolean>`("wt"."actualEnd" - "wt"."actualStart") - ("pt"."plannedEnd" - "pt"."plannedStart") > interval '2 hours'`,
]),
)
.execute();
}
}

View File

@ -14,6 +14,7 @@ import { EmployeeService } from 'src/modules/feat-business-objects/entities/empl
import { PlannedTimeService } from 'src/modules/feat-business-objects/entities/planned-time/planned-time.service'; import { PlannedTimeService } from 'src/modules/feat-business-objects/entities/planned-time/planned-time.service';
import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service'; import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service';
import { WorkTimeService } from 'src/modules/feat-business-objects/entities/work-time/work-time.service'; import { WorkTimeService } from 'src/modules/feat-business-objects/entities/work-time/work-time.service';
import { AnomalyDetectorRepository } from './anomaly-detector.repository';
export type AnomalySkeleton = Pick< export type AnomalySkeleton = Pick<
Prisma.AnomalyCreateInput, Prisma.AnomalyCreateInput,
@ -33,8 +34,36 @@ export class AnomalyDetectorService {
private readonly plannedTimeService: PlannedTimeService, private readonly plannedTimeService: PlannedTimeService,
private readonly tourService: TourService, private readonly tourService: TourService,
private readonly workTimeService: WorkTimeService, private readonly workTimeService: WorkTimeService,
private readonly anomalyRepository: AnomalyDetectorRepository,
) {} ) {}
public findToursWithoutGermanZipInInterval(
interval: Interval,
): Promise<Tour[]> {
return this.anomalyRepository.toursWithoutGermanZipInInterval(interval);
}
public findEmployeeStillWorking(
date: Date,
): Promise<{ employeeId: number; checkIn: Date; checkOut: Date }[]> {
return this.anomalyRepository.employeeStillWorking(date);
}
public findAttendanceBeforeWorkTime(day: Date): Promise<
{
userId: string;
checkInTime: Date;
startWorkTime: Date;
name: string;
}[]
> {
return this.anomalyRepository.attendanceBeforeWorkTime(day);
}
public findWorkForPlannedTime(day: Date) {
return this.anomalyRepository.findWorkForPlannedTime(day);
}
/** /**
* Find a list of tours in a given time interval * Find a list of tours in a given time interval
* @param interval An interval defining the time range of tours * @param interval An interval defining the time range of tours

View File

@ -11,6 +11,9 @@ export class DriverCertificateDetector extends AnomalyDetector {
readonly type = AnomalyTypes.DriverCertificate; readonly type = AnomalyTypes.DriverCertificate;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> { async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${DriverCertificateDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const toursInRange = await this.dataService.findToursInInterval(inInterval); const toursInRange = await this.dataService.findToursInInterval(inInterval);
const anomalies = await Promise.all( const anomalies = await Promise.all(

View File

@ -10,6 +10,9 @@ export class DriverCodriverSameDetector extends AnomalyDetector {
readonly type = AnomalyTypes.DriverCodriverSame; readonly type = AnomalyTypes.DriverCodriverSame;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> { async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${DriverCodriverSameDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const toursInRange = await this.dataService.findToursInInterval( const toursInRange = await this.dataService.findToursInInterval(
inInterval, inInterval,
['KTW'], ['KTW'],

View File

@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { AnomalyDetector } from './anomaly-detector.class';
import { Prisma, Anomaly } from '@prisma/client';
import { eachDayOfInterval, format, Interval, parse } from 'date-fns';
import { AnomalyTypes } from 'src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum';
import { UNGROUPED_ANOMALIES } from 'src/modules/feat-business-objects/entities/anomaly/anomalies.token';
@Injectable()
export class EmployeeStillWorkingDetector extends AnomalyDetector {
private readonly logger = new Logger(EmployeeStillWorkingDetector.name);
readonly type = AnomalyTypes.EmployeeStillWorking;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${EmployeeStillWorkingDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const anomalies = [];
for (const day of eachDayOfInterval(inInterval)) {
const longWorkingEmployees =
await this.dataService.findEmployeeStillWorking(day);
console.log(longWorkingEmployees)
if (longWorkingEmployees.length > 0) {
anomalies.push(
this.createAnomaly(
`${format(longWorkingEmployees.at(0).checkIn, 'yyyy/MM/dd')}-${this.type}`,
longWorkingEmployees.at(0).checkIn,
),
);
}
}
return anomalies;
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const day = parse(anomaly.id.split('-').at(0), 'yyyy/MM/dd', new Date());
const longWorkingEmployees =
await this.dataService.findEmployeeStillWorking(day);
if (longWorkingEmployees.length == 0) {
return {
...anomaly,
resolvedAt: new Date(),
};
}
return {
...anomaly,
};
}
createDescription({ day }: { day: Date }): string {
return `${format(day, 'dd.MM.yyyy')}: Mitarbeiter arbeiten länger als 24 Stunden`;
}
createSolution({ day }: { day: Date }): Prisma.SolutionTargetCreateInput[] {
return [
{
name: 'Dyflexis',
steps: {
create: [
{
description: `Öffnen Sie die Stundenregistrierung für den <a class="dyflexis-btn" href="https://app.planning.nu/avicennaambulance/hamburg/ur3/info_day?periode=${format(day, 'yyyy-MM-dd')}" target="_blank">${format(day, 'dd.MM.yyyy')}</a>.`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
day: Date,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: UNGROUPED_ANOMALIES,
groupDescription: '',
solution: {
create: this.createSolution({ day }),
},
description: this.createDescription({ day }),
};
}
}

View File

@ -22,6 +22,9 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
readonly type = AnomalyTypes.EmployeesLoggedIn; readonly type = AnomalyTypes.EmployeesLoggedIn;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> { async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${EmployeesLoggedInDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const toursInRange = await this.dataService.findToursInInterval(inInterval); const toursInRange = await this.dataService.findToursInInterval(inInterval);
const anomaliesByTour = await Promise.all( const anomaliesByTour = await Promise.all(
@ -41,14 +44,17 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
const employeeId = remainingIdParts.slice(0, -1).join('-'); const employeeId = remainingIdParts.slice(0, -1).join('-');
const tour = await this.dataService.findTourByOperationId(operationId); const tour = await this.dataService.findTourByOperationId(operationId);
const employee = await this.dataService.findEmployeeById(employeeId); const employee = await this.dataService.findEmployeeById(employeeId);
if(!tour?.startDate) {
this.logger.error(`tour ${tour?.operationId} has no startDate`)
}
const startOfTour = this.getStartOfTour( const startOfTour = this.getStartOfTour(
new Date(tour.startDate), new Date(tour?.startDate),
[ [
tour.startBegin, tour?.startBegin,
tour.startEnd, tour?.startEnd,
tour.target, tour?.target,
tour.targetBegin, tour?.targetBegin,
tour.empty, tour?.empty,
].filter((val) => !!val)[0], ].filter((val) => !!val)[0],
); );

View File

@ -0,0 +1,99 @@
import { Injectable, Logger } from '@nestjs/common';
import { AnomalyDetector } from './anomaly-detector.class';
import { Prisma, Anomaly } from '@prisma/client';
import { eachDayOfInterval, format, Interval } from 'date-fns';
import { AnomalyTypes } from 'src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum';
import { UNGROUPED_ANOMALIES } from 'src/modules/feat-business-objects/entities/anomaly/anomalies.token';
import { toZonedTime } from 'date-fns-tz';
@Injectable()
export class IsAttendanceBeforeWorktimeDetector extends AnomalyDetector {
private logger = new Logger(IsAttendanceBeforeWorktimeDetector.name);
readonly type = AnomalyTypes.IsAttendanceBeforeWorktime;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${IsAttendanceBeforeWorktimeDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const anomalies = [];
for (const day of eachDayOfInterval(inInterval)) {
const attendancesAfterWorktime =
await this.dataService.findAttendanceBeforeWorkTime(day);
for (const attendanceAfterWorkTime of attendancesAfterWorktime) {
const { userId, checkInTime, startWorkTime, name } =
attendanceAfterWorkTime;
anomalies.push(
this.createAnomaly(
`${format(checkInTime, 'yyyy/MM/dd_HH:mm:ss')}-${userId}-${this.type}`,
day,
name,
checkInTime,
startWorkTime,
),
);
}
}
return anomalies;
}
validate(anomaly: Anomaly): Promise<Anomaly> {
return
}
createDescription({ day, name }: { day: Date; name: string }): string {
return `${format(day, 'dd.MM.yyyy')}: ${name}'s Stempelzeit liegt in/nach der Arbeitszeit`;
}
createSolution({
day,
name,
checkIn,
workTime,
}: {
day: Date;
name: string;
checkIn: Date;
workTime: Date;
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: 'Dyflexis',
steps: {
create: [
{
description: `${name} hat sich am ${format(day, 'dd.MM.yy')}, um ${format(toZonedTime(checkIn, 'Europe/Berlin'), 'HH:mm')} Uhr eingestempelt, doch bereits um ${format(toZonedTime(workTime, 'Europe/Berlin'), 'HH:mm')} Uhr mit der Arbeit begonnen.`,
},
{
description: `Öffnen Sie die Stundenregistrierung für den <a class="dyflexis-btn" href="https://app.planning.nu/avicennaambulance/hamburg/ur3/info_day?periode=${format(day, 'yyyy-MM-dd')}" target="_blank">${format(day, 'dd.MM.yyyy')}</a>.`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
day: Date,
employeeName: string,
checkInTime: Date,
workTime: Date,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: UNGROUPED_ANOMALIES,
groupDescription: '',
solution: {
create: this.createSolution({
day,
name: employeeName,
checkIn: checkInTime,
workTime,
}),
},
description: this.createDescription({ day, name: employeeName }),
};
}
}

View File

@ -0,0 +1,83 @@
import { Prisma, Anomaly } from '@prisma/client';
import { format, Interval } from 'date-fns';
import { AnomalyDetector } from './anomaly-detector.class';
import { Logger } from '@nestjs/common';
import { AnomalyTypes } from 'src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum';
import { toZonedTime } from 'date-fns-tz';
export class IsGermanZipDetector extends AnomalyDetector {
private readonly logger = new Logger(IsGermanZipDetector.name);
readonly type = AnomalyTypes.IsGermanZip;
async detect(interval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${IsGermanZipDetector.name}' with interval ${format(interval.start, 'yyyy-MM-dd HH:mm')} - ${format(interval.end, 'yyyy-MM-dd HH:mm')}`,
);
const tours = await this.dataService.findToursWithoutGermanZipInInterval(interval)
return await Promise.all(
tours.map(tour => this.createAnomaly(`${tour.operationId}-${this.type}`, tour.id, tour.operationId, tour.startDate))
)
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId] = anomaly.id.split('-')
try {
const tour = await this.dataService.findTourByOperationId(operationId)
if(tour?.startZip.length == 5 && tour?.targetZip.length == 5) {
return {
...anomaly,
resolvedAt: new Date()
}
} else {
return {
...anomaly
}
}
} catch (error) {
this.logger.error(error, {anomalyId: anomaly.id})
}
}
createDescription({operationId}: {operationId: string}): string {
return `${operationId}: PLZ ungültig`
}
createSolution({tourId}: {tourId: string}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: 'DispoLive',
steps: {
create: [
{
description: `Öffnen Sie den <a class="dispolive-btn" href="https://avicenna.dispolive.de/fahrbericht/${tourId}" target="_blank">Fahrbericht</a> der Tour und ändern Sie die fehlerhafte Postleitzahl.`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
tourId: string,
operationId: 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({tourId})
},
description: this.createDescription({operationId})
}
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, Logger } from "@nestjs/common";
import { AnomalyDetector } from "./anomaly-detector.class";
import { Prisma, Anomaly } from "@prisma/client";
import { eachDayOfInterval, format, Interval } from "date-fns";
import { AnomalyTypes } from "src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum";
@Injectable()
export class IsWorkTimePlannedDetector extends AnomalyDetector {
private logger = new Logger(IsWorkTimePlannedDetector.name)
readonly type = AnomalyTypes.IsWorktimePlanned
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${IsWorkTimePlannedDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const anomalies = []
for (const day of eachDayOfInterval(inInterval)){
const workTimeWithoutPlan = await this.dataService.findWorkForPlannedTime(day)
console.log(day, workTimeWithoutPlan)
}
return
}
validate(anomaly: Anomaly): Promise<Anomaly> {
throw new Error("Method not implemented.");
}
createDescription(data: unknown): string {
throw new Error("Method not implemented.");
}
createSolution(data: unknown): Prisma.SolutionTargetCreateInput[] {
throw new Error("Method not implemented.");
}
}

View File

@ -33,6 +33,9 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
} }
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> { async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${OneTourAtATimeDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const toursInRange = await this.dataService.findToursInInterval(inInterval); const toursInRange = await this.dataService.findToursInInterval(inInterval);
const toursMap = this.groupToursByDay(toursInRange); const toursMap = this.groupToursByDay(toursInRange);

View File

@ -14,6 +14,9 @@ export class WorkingEmployeeNotInDistributionSystemDetector extends AnomalyDetec
readonly type = AnomalyTypes.WorkingEmployeeNotInDistributionSystem; readonly type = AnomalyTypes.WorkingEmployeeNotInDistributionSystem;
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> { async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${WorkingEmployeeNotInDistributionSystemDetector.name}' with interval ${format(inInterval.start, 'yyyy-MM-dd HH:mm')} - ${format(inInterval.end, 'yyyy-MM-dd HH:mm')}`,
);
const attendanceRegistrationInInterval = const attendanceRegistrationInInterval =
await this.dataService.findAttendanceRegistrationInInterval(inInterval, [ await this.dataService.findAttendanceRegistrationInInterval(inInterval, [
AttendanceRegistrationEvents.ClockedIn, AttendanceRegistrationEvents.ClockedIn,

View File

@ -1 +1,2 @@
export const ANOMALY_SLEEP_TIME_MS = 60 * 60 * 1000; export const ANOMALY_SLEEP_TIME_MS = 60 * 60 * 1000;
export const UNGROUPED_ANOMALIES = 'ungrouped';

View File

@ -2,6 +2,10 @@ export enum AnomalyTypes {
DriverCodriverSame = 'DriverCodriverSame', DriverCodriverSame = 'DriverCodriverSame',
DriverCertificate = 'DriverCertificate', DriverCertificate = 'DriverCertificate',
EmployeesLoggedIn = 'EmployeesLoggedIn', EmployeesLoggedIn = 'EmployeesLoggedIn',
EmployeeStillWorking = 'EmployeeStillWorking',
IsAttendanceBeforeWorktime = 'IsAttendanceBeforeWorktime',
IsGermanZip = 'IsGermanZip',
IsWorktimePlanned = 'IsWorktimePlanned',
OneTourAtATime = 'OneTourAtATime', OneTourAtATime = 'OneTourAtATime',
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem', WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
} }

View File

@ -1,21 +1,29 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { AttendanceRegistration } from '@prisma/client'; import { AttendanceRegistration } from '@prisma/client';
import { Interval } from 'date-fns'; import { Interval } from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service'; import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { AttendanceRegistrationEvents } from './attendance-registration-events.enum'; import { AttendanceRegistrationEvents } from './attendance-registration-events.enum';
@Injectable() @Injectable()
export class AttendanceRegistrationRepository { export class AttendanceRegistrationRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async findById( async findById(
attendanceRegistrationId: number, attendanceRegistrationId: number,
): Promise<AttendanceRegistration> { ): Promise<AttendanceRegistration> {
return await this.prisma.attendanceRegistration.findUnique({ return await this.db
where: { .selectFrom('AttendanceRegistration')
.selectAll()
.where(
'AttendanceRegistration.attendanceRegistrationId',
'=',
attendanceRegistrationId, attendanceRegistrationId,
}, )
}); .executeTakeFirst();
} }
async findInInterval( async findInInterval(
@ -25,28 +33,14 @@ export class AttendanceRegistrationRepository {
AttendanceRegistrationEvents.ClockedOut, AttendanceRegistrationEvents.ClockedOut,
], ],
): Promise<AttendanceRegistration[]> { ): Promise<AttendanceRegistration[]> {
return await this.prisma.attendanceRegistration.findMany({ return await this.db
where: { .selectFrom('AttendanceRegistration')
event: { .selectAll()
in: eventTypes, .where('AttendanceRegistration.event', 'in', eventTypes)
}, .where('AttendanceRegistration.dateTime', '>=', new Date(interval.start))
AND: [ .where('AttendanceRegistration.dateTime', '<', new Date(interval.end))
{ .orderBy('AttendanceRegistration.dateTime asc')
dateTime: { .execute();
gte: new Date(interval.start),
},
},
{
dateTime: {
lt: new Date(interval.end),
},
},
],
},
orderBy: {
dateTime: 'asc',
},
});
} }
async findByEmployeeInInterval( async findByEmployeeInInterval(
@ -57,28 +51,14 @@ export class AttendanceRegistrationRepository {
AttendanceRegistrationEvents.ClockedOut, AttendanceRegistrationEvents.ClockedOut,
], ],
): Promise<AttendanceRegistration[]> { ): Promise<AttendanceRegistration[]> {
return await this.prisma.attendanceRegistration.findMany({ return await this.db
where: { .selectFrom('AttendanceRegistration')
employeeId, .selectAll()
event: { .where('AttendanceRegistration.employeeId', '=', employeeId)
in: eventTypes, .where('AttendanceRegistration.event', 'in', eventTypes)
}, .where('AttendanceRegistration.dateTime', '>=', new Date(timeRange.start))
AND: [ .where('AttendanceRegistration.dateTime', '<', new Date(timeRange.end))
{ .orderBy('AttendanceRegistration.dateTime asc')
dateTime: { .execute();
gte: new Date(timeRange.start),
},
},
{
dateTime: {
lt: new Date(timeRange.end),
},
},
],
},
orderBy: {
dateTime: 'asc',
},
});
} }
} }

View File

@ -1,16 +1,28 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Employee } from '@prisma/client'; import { Employee } from '@prisma/client';
import { PrismaService } from 'src/core/database/prisma.service'; import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
@Injectable() @Injectable()
export class EmployeeRepository { export class EmployeeRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async findById(employeeId: string): Promise<Employee> { async findById(employeeId: string): Promise<Employee> {
return await this.prisma.employee.findUnique({ where: { id: employeeId } }); return await this.db
.selectFrom('Employee')
.selectAll()
.where('Employee.id', '=', employeeId)
.executeTakeFirst();
} }
async findByPersonnelNumber(personnelNumber: string): Promise<Employee> { async findByPersonnelNumber(personnelNumber: string): Promise<Employee> {
return await this.prisma.employee.findFirst({ where: { personnelNumber } }); return await this.db
.selectFrom('Employee')
.selectAll()
.where('Employee.personnelNumber', '=', personnelNumber)
.executeTakeFirst();
} }
} }

View File

@ -1,11 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { PlannedTime } from '@prisma/client'; import { PlannedTime } from '@prisma/client';
import { Interval } from 'date-fns'; import { Interval, intervalToDuration } from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service'; import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types';
@Injectable() @Injectable()
export class PlannedTimeRepository { export class PlannedTimeRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async findById(id: string): Promise<PlannedTime> { async findById(id: string): Promise<PlannedTime> {
return await this.prisma.plannedTime.findUnique({ return await this.prisma.plannedTime.findUnique({
@ -17,23 +23,13 @@ export class PlannedTimeRepository {
personnelNumber: string, personnelNumber: string,
interval: Interval, interval: Interval,
): Promise<PlannedTime[]> { ): Promise<PlannedTime[]> {
return await this.prisma.plannedTime.findMany({ return await this.db
where: { .selectFrom('PlannedTime')
userId: personnelNumber, .selectAll()
AND: [ .where('PlannedTime.userId', '=', personnelNumber)
{ .where('PlannedTime.startDate', '>=', new Date(interval.start))
startDate: { .where('PlannedTime.startDate', '<', new Date(interval.end))
gte: new Date(interval.start), .execute();
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
],
},
});
} }
async findByOrdinanceTypesInInterval( async findByOrdinanceTypesInInterval(

View File

@ -133,18 +133,13 @@ export class TicketRepository {
} }
async findIdByTourId(tourId: string): Promise<{ id: string }> { async findIdByTourId(tourId: string): Promise<{ id: string }> {
return await this.prisma.ticket.findFirst({ return await this.db
select: { .selectFrom('Ticket')
id: true, .select(['Ticket.id'])
}, .innerJoin('Tour', 'Tour.ticketId', 'Ticket.id')
where: { .where('Tour.id', '=', tourId)
tours: { .limit(1)
some: { .executeTakeFirst();
id: tourId,
},
},
},
});
} }
async search(query: string): Promise<(Ticket & { tours: Tour[] })[]> { async search(query: string): Promise<(Ticket & { tours: Tour[] })[]> {

View File

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Prisma, Tour } from '@prisma/client'; import { Prisma, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns'; import { endOfDay, Interval, startOfDay } from 'date-fns';
import { Kysely } from 'kysely'; import { ExpressionBuilder, Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module'; import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service'; import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types'; import { DB } from 'src/core/database/types';
@ -12,6 +12,11 @@ import {
TourAccountingFilterDto, TourAccountingFilterDto,
} from './tours.filter'; } from './tours.filter';
type TourEb = ExpressionBuilder<DB, 'Tour'>;
const PAUSE_PATIENT_ID = 'b7424477-a4b2-42f7-b7e7-a4bef24f6979';
const FEIERABEND_PATIENT_ID = '3554e1a9-9c07-45e5-b53c-692a77e4fa9a';
function onlyRealTours(): Prisma.TourWhereInput[] { function onlyRealTours(): Prisma.TourWhereInput[] {
return [ return [
{ {
@ -58,13 +63,13 @@ function onlyRealTours(): Prisma.TourWhereInput[] {
{ {
patientId: { patientId: {
// Pause // Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979', not: PAUSE_PATIENT_ID,
}, },
}, },
{ {
patientId: { patientId: {
// Feierabend // Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a', not: FEIERABEND_PATIENT_ID,
}, },
}, },
], ],
@ -74,6 +79,24 @@ function onlyRealTours(): Prisma.TourWhereInput[] {
]; ];
} }
function onlyRealToursKysely(eb: TourEb) {
return eb.and([
eb.or([
eb('carName', 'is', null),
eb('carName', 'not in', ['Storno', 'Löschen', 'Abgabe']),
]),
eb('check', '!=', 3),
eb('deletedAt', 'is', null),
eb.or([
eb('patientId', 'is', null),
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
]),
]);
}
@Injectable() @Injectable()
export class TourRepository { export class TourRepository {
constructor( constructor(
@ -164,42 +187,24 @@ export class TourRepository {
employeeId: string, employeeId: string,
ordinanceTypes: string[] = [], ordinanceTypes: string[] = [],
): Promise<Tour[]> { ): Promise<Tour[]> {
return this.prisma.tour.findMany({ return this.db
where: { .selectFrom('Tour')
AND: [ .selectAll()
{ .where((eb) =>
OR: [ eb.and([
{ onlyRealToursKysely(eb),
driverId: employeeId, eb.or([
}, eb('Tour.driverId', '=', employeeId),
{ eb('Tour.codriverId', '=', employeeId),
codriverId: employeeId, ]),
}, eb('Tour.startDate', '>=', new Date(start)),
], eb('Tour.startDate', '<', new Date(end)),
}, ...(ordinanceTypes.length > 0
{ ? [eb('Tour.ordinanceType', 'in', ordinanceTypes)]
startDate: {
gte: new Date(start),
},
},
{
startDate: {
lt: new Date(end),
},
},
{
OR: [
...(ordinanceTypes.length
? ordinanceTypes.map((ot) => ({
ordinanceType: { contains: ot },
}))
: []), : []),
], ]),
}, )
...onlyRealTours(), .execute();
],
},
});
} }
async findByTicketId(ticketId: string): Promise<Tour[]> { async findByTicketId(ticketId: string): Promise<Tour[]> {

View File

@ -1,38 +1,34 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { WorkTime } from '@prisma/client'; import { WorkTime } from '@prisma/client';
import { Interval } from 'date-fns'; import { Interval } from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service'; import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
@Injectable() @Injectable()
export class WorkTimeRepository { export class WorkTimeRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async findById(id: string): Promise<WorkTime> { async findById(id: string): Promise<WorkTime> {
return await this.prisma.workTime.findUnique({ return await this.db
where: { id }, .selectFrom('WorkTime')
}); .selectAll()
.where('WorkTime.id', '=', id)
.executeTakeFirst();
} }
async findByPersonnelNumberInInterval( async findByPersonnelNumberInInterval(
personnelNumber: string, personnelNumber: string,
interval: Interval, interval: Interval,
): Promise<WorkTime> { ): Promise<WorkTime> {
return await this.prisma.workTime.findFirst({ return await this.db
where: { .selectFrom('WorkTime')
userId: personnelNumber, .selectAll()
AND: [ .where('WorkTime.userId', '=', personnelNumber)
{ .where('WorkTime.startDate', '>=', new Date(interval.start))
startDate: { .where('WorkTime.startDate', '<=', new Date(interval.end))
gte: new Date(interval.start), .executeTakeFirst();
},
},
{
startDate: {
lte: new Date(interval.end),
},
},
],
},
});
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module'; import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { CalculationService } from './calculation.service'; import { CalculationService } from './calculation.service';
import { PersonnelCostCalculationService } from './personnel-cost-calculation.service'; import { PersonnelCostCalculationService } from './personnel-cost-calculation.service';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
@Module({ @Module({
imports: [PrismaModule, FeatBusinessObjectsModule], imports: [PrismaModule, FeatBusinessObjectsModule],

View File

@ -6,6 +6,7 @@ import { DyflexisExtractorsService } from './dyflexis-extractors.service';
import { ExtractorsService } from './extractors.service'; import { ExtractorsService } from './extractors.service';
import { SourceRepository } from './source.repository'; import { SourceRepository } from './source.repository';
import { DispoLiveApiExtractorsService } from './dispo-live-api-extractors.service'; import { DispoLiveApiExtractorsService } from './dispo-live-api-extractors.service';
import { DyflexisUserAccessExtractorsService } from './dyflexis-user-access-extractors.service';
@Module({ @Module({
imports: [PrismaModule, HttpModule], imports: [PrismaModule, HttpModule],
@ -13,6 +14,7 @@ import { DispoLiveApiExtractorsService } from './dispo-live-api-extractors.servi
ExtractorsService, ExtractorsService,
DispoLiveExtractorsService, DispoLiveExtractorsService,
DispoLiveApiExtractorsService, DispoLiveApiExtractorsService,
DyflexisUserAccessExtractorsService,
DyflexisExtractorsService, DyflexisExtractorsService,
SourceRepository, SourceRepository,
], ],
@ -20,6 +22,7 @@ import { DispoLiveApiExtractorsService } from './dispo-live-api-extractors.servi
ExtractorsService, ExtractorsService,
DispoLiveApiExtractorsService, DispoLiveApiExtractorsService,
DispoLiveExtractorsService, DispoLiveExtractorsService,
DyflexisUserAccessExtractorsService,
DyflexisExtractorsService DyflexisExtractorsService
], ],
}) })

View File

@ -72,6 +72,7 @@ export class DyflexisExtractorsService {
contract_end, contract_end,
contract_hours_week, contract_hours_week,
contract_salary_hour, contract_salary_hour,
contract_type_name,
} = employee; } = employee;
return { return {
id, id,
@ -82,6 +83,7 @@ export class DyflexisExtractorsService {
contract_end, contract_end,
contract_hours_week, contract_hours_week,
contract_salary_hour, contract_salary_hour,
contract_type_name,
}; };
}), }),
), ),

View File

@ -0,0 +1,274 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { SourceRepository } from './source.repository';
import { ConfigService } from 'src/core/config/config.service';
import {
catchError,
EMPTY,
expand,
forkJoin,
from,
map,
Observable,
of,
reduce,
switchMap,
tap,
} from 'rxjs';
import { format, Interval } from 'date-fns';
import { HolidayRaw, SickDaysRaw } from './source.interface';
interface DyflexisAuthHeader {
Cookie: string;
'X-Authentication-Csrf-Token': string;
}
interface DyflexisAppUser {
id: number;
publicId: string;
name: string;
initials: string;
email: string;
}
@Injectable()
export class DyflexisUserAccessExtractorsService {
constructor(
private readonly http: HttpService,
private readonly repository: SourceRepository,
private readonly config: ConfigService,
) {}
loadData(): Observable<any> {
// return this.extractSicknessDays({
// start: new Date('2025-01-01T00:00:00.000Z'),
// end: new Date('2025-12-31T23:59:59.999Z'),
// });
return this.extractHolidays(new Date('2025-01-01T00:00:00.000Z'));
}
extractHolidays(year: Date): Observable<any> {
return this.dyflexisLogin(
this.config.dyflexisAppUsername(),
this.config.dyflexisAppPassword(),
).pipe(
switchMap((authHeader) =>
forkJoin({
authHeader: of(authHeader),
employees: this.fetchEmployees(authHeader),
}),
),
switchMap(({ authHeader, employees }) =>
forkJoin(
employees.map((employee) =>
this.fetchHolidaysForEmployee(authHeader, employee.id, year),
),
),
),
map((holidaysPerEmployee) => holidaysPerEmployee.flat(1)),
switchMap((holidays) => from(this.repository.createHolidays(holidays))),
map((res) => {
return { count: Number(res.at(0).numInsertedOrUpdatedRows) };
}),
);
}
private fetchHolidaysForEmployee(
authHeader: DyflexisAuthHeader,
employeeId: number,
year: Date,
): Observable<(HolidayRaw & { employeeId: number })[]> {
const endpoint = (employeeId: number, year: string) =>
`hamburg/balance/holiday-balance-in-days/employee-balance-transaction/list/${employeeId}/${year}?sortBy=bookDate&descending=true&page=1&rowsPerPage=50`;
return this.http
.get<{
employeeBalanceTransactions: (HolidayRaw & { amountOfDays: number })[];
}>(
`${this.config.dyflexisAppBaseUrl()}${endpoint(employeeId, year.getFullYear().toString())}`,
{
headers: {
...authHeader,
},
},
)
.pipe(
map((response) =>
response.data.employeeBalanceTransactions
.filter(({ amountOfDays }) => amountOfDays < 0)
.map((holiday) => {
const { bookDate, isExcludedFromPayroll, isLocked } = holiday;
return { bookDate, isLocked, isExcludedFromPayroll, employeeId };
}),
),
);
}
extractSicknessDays(interval: Interval): Observable<any> {
return this.dyflexisLogin(
this.config.dyflexisAppUsername(),
this.config.dyflexisAppPassword(),
).pipe(
switchMap((authHeader) =>
forkJoin({
authHeader: of(authHeader),
employees: this.fetchEmployees(authHeader),
}),
),
tap(({ employees }) => console.log(employees.length)),
switchMap(({ authHeader, employees }) =>
forkJoin(
employees.map((employee) =>
this.fetchSicknessDaysForEmployee(
authHeader,
employee.id,
interval,
),
),
),
),
map((sicknessDaysPerEmployee) => sicknessDaysPerEmployee.flat(1)),
switchMap((sicknessDays) =>
from(this.repository.createSickdays(sicknessDays)),
),
map((res) => {
return { count: Number(res.at(0).numInsertedOrUpdatedRows) };
}),
);
}
private fetchSicknessDaysForEmployee(
authHeader: DyflexisAuthHeader,
employeeId: number,
interval: Interval,
): Observable<(SickDaysRaw & { employeeId: number })[]> {
const endpoint = 'employee-management/sick-leave-registration-list/';
return this.http
.get<{
sickLeaveRegistrations: SickDaysRaw[];
}>(
`${this.config.dyflexisAppBaseUrl()}${endpoint}${employeeId}?startDate=${format(interval.start, 'yyyy-MM-dd')}&endDate=${format(interval.end, 'yyyy-MM-dd')}`,
{
headers: {
...authHeader,
},
},
)
.pipe(
map((response) =>
response.data.sickLeaveRegistrations.map((sickDay) => {
const { startDate, endDate, days, hours, note, locked } = sickDay;
return {
startDate,
endDate,
days,
hours,
note,
locked,
employeeId,
};
}),
),
);
}
private fetchEmployees(
authHeader: DyflexisAuthHeader,
): Observable<DyflexisAppUser[]> {
const employeeListEndpoint = 'employee-management/employee-list';
const getEmployeesPage = (pageNumber: number) =>
this.http
.get<{ employees: DyflexisAppUser[] }>(
`${this.config.dyflexisAppBaseUrl()}${employeeListEndpoint}?page=${pageNumber}`,
{
headers: {
...authHeader,
'Content-Type': 'application/json',
},
},
)
.pipe(
map((response) =>
response.data.employees
.filter(({ name }) => name.indexOf('Shahrasebi') == -1)
.map((employee) => {
const { id, name, email } = employee;
return { id, name, email };
}),
),
);
return new Observable<any>((observer) =>
getEmployeesPage(1)
.pipe(
expand((data, i) => (data.length ? getEmployeesPage(i + 2) : EMPTY)),
reduce((acc, data) => [...acc, ...data], []),
catchError((error) => {
observer.error(error);
return of(error);
}),
)
.subscribe((employees) => {
observer.next(employees);
observer.complete();
}),
);
}
private dyflexisLogin(
username: string,
password: string,
): Observable<DyflexisAuthHeader> {
return this.http
.get('https://app.planning.nu/avicennaambulance/login')
.pipe(
map((response) => {
const cookie = response.headers['set-cookie']?.at(0).split(';').at(0);
const html = response.data;
const nameAttr = 'authentication-csrf-token';
const regex = new RegExp(
`<meta[^>]*name="${nameAttr}"[^>]*content="([^"]*)"[^>]*>`,
'i',
);
const match = html.match(regex);
if (match && match[1]) {
return {
Cookie: cookie,
'X-Authentication-Csrf-Token': match[1],
};
}
return null;
}),
switchMap(({ Cookie, 'X-Authentication-Csrf-Token': csrfToken }) =>
this.http
.post(
'https://app.planning.nu/avicennaambulance/login/authenticate',
{
username,
password,
authenticatorCode: '',
rememberDevice: false,
},
{
headers: {
'Content-Type': 'application/json',
Cookie: Cookie,
'X-Authentication-Csrf-Token': csrfToken,
},
},
)
.pipe(
map((res) => ({
Cookie: res.headers['set-cookie']?.at(0).split(';').at(0),
'X-Authentication-Csrf-Token': csrfToken,
})),
),
),
);
}
}

View File

@ -68,6 +68,9 @@ export class TourExtractor {
beifahrerName, beifahrerName,
fahrer, fahrer,
fahrerName, fahrerName,
overRTW,
firmName,
schwerlast,
color, color,
} = tour; } = tour;
return { return {
@ -135,6 +138,9 @@ export class TourExtractor {
beifahrerName: beifahrerName ? `${beifahrerName}` : undefined, beifahrerName: beifahrerName ? `${beifahrerName}` : undefined,
fahrer: fahrer ? `${fahrer}` : undefined, fahrer: fahrer ? `${fahrer}` : undefined,
fahrerName: fahrerName ? `${fahrerName}` : undefined, fahrerName: fahrerName ? `${fahrerName}` : undefined,
overRTW: overRTW ? `${overRTW}` : undefined,
companyName: firmName ? `${firmName}` : undefined,
schwerlast: schwerlast ? `${schwerlast}` : undefined,
color: color ? `${color}` : undefined, color: color ? `${color}` : undefined,
}; };
} }

View File

@ -68,6 +68,10 @@ export interface TourRaw {
fahrerName?: string; fahrerName?: string;
fahrer?: string; // ID fahrer?: string; // ID
overRTW?: string;
firmName?: string;
schwerlast?: string;
color?: string; // Hex rgb color?: string; // Hex rgb
} }
@ -141,6 +145,7 @@ export interface EmployeeDyflexisRaw {
contract_end?: string; // yyyy-MM-dd contract_end?: string; // yyyy-MM-dd
contract_hours_week?: string; // Float string contract_hours_week?: string; // Float string
contract_salary_hour?: string; // Float string contract_salary_hour?: string; // Float string
contract_type_name?: string;
} }
export interface TourSummaryDto { export interface TourSummaryDto {
@ -161,20 +166,36 @@ export interface FahrzeugplanungItemDto {
} }
export interface FahrzeugItemDto { export interface FahrzeugItemDto {
_id: string _id: string;
bezeichnung: string bezeichnung: string;
marke: string marke: string;
tuev: string tuev: string;
conn: string conn: string;
carStatus: string carStatus: string;
kaufDatum: string kaufDatum: string;
erstzulassung: string erstzulassung: string;
status: string status: string;
firmaId: string firmaId: string;
kennzeichen: string kennzeichen: string;
kaufPreis: string kaufPreis: string;
kaufKm: string kaufKm: string;
fahrzeugIdentNr: string fahrzeugIdentNr: string;
kfzNr: string kfzNr: string;
verordnungsart: string verordnungsart: string;
}
export interface SickDaysRaw {
startDate: string;
endDate: string;
hours: number;
days: number;
note?: string;
locked: boolean;
}
export interface HolidayRaw {
bookDate: string;
isLocked: boolean;
isExcludedFromPayroll: boolean;
} }

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { import {
AttendanceRegistrationSource, AttendanceRegistrationSource,
EmployeeDispoLiveSource, EmployeeDispoLiveSource,
@ -6,6 +6,7 @@ import {
PatientSource, PatientSource,
PlannedTimeSource, PlannedTimeSource,
Prisma, Prisma,
Sickdays,
TourSource, TourSource,
WorkTimeSource, WorkTimeSource,
} from '@prisma/client'; } from '@prisma/client';
@ -14,15 +15,23 @@ import {
AttendanceRegistrationRaw, AttendanceRegistrationRaw,
EmployeeDispoLiveRaw, EmployeeDispoLiveRaw,
EmployeeDyflexisRaw, EmployeeDyflexisRaw,
HolidayRaw,
PatientRaw, PatientRaw,
PlannedTimeRaw, PlannedTimeRaw,
SickDaysRaw,
TourRaw, TourRaw,
WorkTimeRaw, WorkTimeRaw,
} from './source.interface'; } from './source.interface';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
@Injectable() @Injectable()
export class SourceRepository { export class SourceRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
clearSource(): Promise<Prisma.BatchPayload[]> { clearSource(): Promise<Prisma.BatchPayload[]> {
return Promise.all([ return Promise.all([
@ -54,6 +63,55 @@ export class SourceRepository {
}); });
} }
createSickdays(sickdays: (SickDaysRaw & { employeeId: number })[]) {
return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('Sickdays').execute();
return await this.db
.insertInto('Sickdays')
.values(
sickdays.map(
({
startDate,
endDate,
hours,
days,
note,
locked,
employeeId,
}): Partial<Sickdays> => ({
start: new Date(startDate),
end: new Date(endDate),
hours,
days,
note,
locked,
employeeId,
}),
),
)
.execute();
});
}
createHolidays(holidays: (HolidayRaw & { employeeId: number })[]) {
return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('Holiday').execute();
return await this.db
.insertInto('Holiday')
.values(
holidays.map(
({ bookDate, isExcludedFromPayroll, isLocked, employeeId }) => ({
date: new Date(bookDate),
locked: isLocked,
excludedFromPayroll: isExcludedFromPayroll,
employeeId,
}),
),
)
.execute();
});
}
createPatients( createPatients(
patients: PatientRaw[], patients: PatientRaw[],
jobId: string, jobId: string,

View File

@ -13,6 +13,7 @@ import { LoadingDoneEvent } from './events/loading-done.event';
import { TransformationDoneEvent } from './events/transformation-done.event'; import { TransformationDoneEvent } from './events/transformation-done.event';
import { TourUpdateChecker } from './producer/tour-update'; import { TourUpdateChecker } from './producer/tour-update';
import { SchichtplanungSynchronizer } from './producer/schichtplanung'; import { SchichtplanungSynchronizer } from './producer/schichtplanung';
import { DyflexisUserAccessExtractorsService } from '../feat-extractors/dyflexis-user-access-extractors.service';
@Injectable() @Injectable()
export class ImporterService { export class ImporterService {
@ -24,8 +25,13 @@ export class ImporterService {
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly tourUpdateChecker: TourUpdateChecker, private readonly tourUpdateChecker: TourUpdateChecker,
private readonly schichtplanungSynchronizer: SchichtplanungSynchronizer, private readonly schichtplanungSynchronizer: SchichtplanungSynchronizer,
private readonly dyflexisUserAccessService: DyflexisUserAccessExtractorsService
) {} ) {}
runDyflexisUserAccessTest() {
return this.dyflexisUserAccessService.loadData()
}
runTourUpdateCheck(interval: Interval) { runTourUpdateCheck(interval: Interval) {
return this.tourUpdateChecker.fetchTourUpdates(interval); return this.tourUpdateChecker.fetchTourUpdates(interval);
} }

View File

@ -1,13 +1,13 @@
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, Queue, Worker } from 'bullmq'; import { Logger } from '@nestjs/common';
import { TourSource } from '@prisma/client';
import { Job } from 'bullmq';
import { from, lastValueFrom, map, switchMap } from 'rxjs';
import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service'; import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service';
import { TOURS_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens'; import { TOURS_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import { DispoLiveExtractorsService } from '../../feat-extractors/dispo-live-extractors.service'; import { DispoLiveExtractorsService } from '../../feat-extractors/dispo-live-extractors.service';
import { firstValueFrom, from, lastValueFrom, map, switchMap, tap } from 'rxjs';
import { TourTransformer } from '../../feat-transformers/transformers/tour.transformer';
import { TourSource } from '@prisma/client';
import { ToursCalculationAdapater } from '../../feat-transformers/adapter/tours-calculation.adapter'; import { ToursCalculationAdapater } from '../../feat-transformers/adapter/tours-calculation.adapter';
import { Logger } from '@nestjs/common'; import { TourTransformer } from '../../feat-transformers/transformers/tour.transformer';
@Processor(TOURS_QUEUE, { @Processor(TOURS_QUEUE, {
limiter: { limiter: {
@ -53,9 +53,15 @@ export class ToursConsumer extends WorkerHost {
), ),
); );
if (isCreate || job.name === 'Create') { if (isCreate || job.name === 'Create') {
return await this.service.createTour(tour).then(() => this.logger.log(`Created tour ${tour.id} successfully`)); return await this.service
.createTour(tour)
.then(() =>
this.logger.log(`Created tour ${tour.id} successfully`),
);
} }
return await this.service.updateTour(tour.id, tour).then(() => this.logger.log(`Updated tour ${tour.id} successfully`)); return await this.service
.updateTour(tour.id, tour)
.then(() => this.logger.log(`Updated tour ${tour.id} successfully`));
default: default:
console.log(`No case matched`, job); console.log(`No case matched`, job);
} }

View File

@ -1,5 +1,5 @@
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { addMinutes, endOfDay, getMonth, getYear, isSameDay, startOfDay } from 'date-fns'; import { addMinutes, endOfDay, getMonth, getYear, isSameDay, startOfDay } from 'date-fns';
import { import {
@ -17,6 +17,8 @@ import { SCHICHTPLANUNG_QUEUE } from '../tokens';
@Injectable() @Injectable()
export class SchichtplanungSynchronizer { export class SchichtplanungSynchronizer {
private logger = new Logger(SchichtplanungSynchronizer.name)
constructor( constructor(
private readonly dlApiService: DispoLiveApiExtractorsService, private readonly dlApiService: DispoLiveApiExtractorsService,
private readonly dyflexisService: DyflexisExtractorsService, private readonly dyflexisService: DyflexisExtractorsService,
@ -61,7 +63,7 @@ export class SchichtplanungSynchronizer {
plannedTime.user_id, plannedTime.user_id,
); );
if (!employee) { if (!employee) {
console.error(`couldn\'t find employee with id ${plannedTime.user_id}`); this.logger.error(`couldn\'t find employee with id ${plannedTime.user_id}`);
continue; continue;
} }

View File

@ -68,7 +68,7 @@ export class AttendanceRegistrationLoader {
private static attendanceRegistrationStagingToFinal( private static attendanceRegistrationStagingToFinal(
attendanceregistration: AttendanceRegistrationStaging, attendanceregistration: AttendanceRegistrationStaging,
): Prisma.AttendanceRegistrationCreateInput { ): Prisma.AttendanceRegistrationCreateInput {
const { attendanceRegistrationId, employeeId, dateTime, event } = const { attendanceRegistrationId, employeeId, dateTime, event, personnelNumber } =
attendanceregistration; attendanceregistration;
return { return {
@ -76,7 +76,7 @@ export class AttendanceRegistrationLoader {
employeeId, employeeId,
dateTime, dateTime,
event, event,
personnelNumber: '', personnelNumber,
}; };
} }
} }

View File

@ -56,6 +56,7 @@ export class EmployeeDyflexisLoader {
contractEnd, contractEnd,
contractHoursWeek, contractHoursWeek,
contractSalaryHour, contractSalaryHour,
contractTypeName
} = employee; } = employee;
return { return {
@ -66,6 +67,7 @@ export class EmployeeDyflexisLoader {
contractEnd, contractEnd,
contractHoursWeek, contractHoursWeek,
contractSalaryHour, contractSalaryHour,
contractTypeName
}; };
} }
} }

View File

@ -56,6 +56,8 @@ export class PlannedTimeLoader {
startDate, startDate,
endDate, endDate,
mark, mark,
pause,
duration,
} = plannedtime; } = plannedtime;
return { return {
@ -63,11 +65,11 @@ export class PlannedTimeLoader {
surname, surname,
startDate, startDate,
endDate, endDate,
pause,
duration,
id: `${id}`, id: `${id}`,
userId: `${employeeId}`, userId: `${employeeId}`,
department: departmentName, department: departmentName,
pause: 0,
duration: 0,
note: mark, note: mark,
}; };
} }

View File

@ -91,10 +91,13 @@ export class TourLoader {
revenue, revenue,
revenueDeviation, revenueDeviation,
consumptionCosts, consumptionCosts,
overRTW,
companyName,
schwerlast,
billDate, billDate,
billNumber, billNumber,
createdAt, createdAt,
updatedAt updatedAt,
} = tour; } = tour;
return { return {
@ -143,6 +146,9 @@ export class TourLoader {
revenue, revenue,
revenueDeviation, revenueDeviation,
consumptionCosts, consumptionCosts,
overRTW,
companyName,
schwerlast,
billDate, billDate,
billNumber, billNumber,
createdAt, createdAt,

View File

@ -53,6 +53,8 @@ export class WorkTimeLoader {
departmentName, departmentName,
startDate, startDate,
endDate, endDate,
pause,
duration
} = worktime; } = worktime;
return { return {
@ -60,11 +62,11 @@ export class WorkTimeLoader {
surname, surname,
startDate, startDate,
endDate, endDate,
pause,
duration,
id: `${id}`, id: `${id}`,
userId: `${employeeId}`, userId: `${employeeId}`,
department: departmentName, department: departmentName,
pause: 0,
duration: 0,
}; };
} }
} }

View File

@ -56,6 +56,10 @@ export interface TourPreStaging {
revenueDeviation?: number; revenueDeviation?: number;
consumptionCosts?: number; consumptionCosts?: number;
overRTW?: boolean;
companyName?: string;
schwerlast?: boolean;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
} }
@ -91,6 +95,8 @@ export interface PlannedTimePreStaging {
departmentName: string; departmentName: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
pause: number;
duration: number;
deleted: boolean; deleted: boolean;
mark?: string; mark?: string;
} }
@ -103,6 +109,8 @@ export interface WorkTimePreStaging {
departmentName: string; departmentName: string;
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
pause: number;
duration: number;
} }
export interface EmployeeDyflexisPreStaging { export interface EmployeeDyflexisPreStaging {

View File

@ -23,7 +23,7 @@ export class AttendanceRegistrationTransformer {
), ),
}; };
const { attendanceRegistrationId, employeeId, dateTime, event } = const { attendanceRegistrationId, employeeId, dateTime, event, personnelNumber } =
attendanceRegistrationParsed; attendanceRegistrationParsed;
return { return {
@ -31,6 +31,7 @@ export class AttendanceRegistrationTransformer {
employeeId, employeeId,
dateTime, dateTime,
event, event,
personnelNumber,
} as unknown as AttendanceRegistrationPreStaging; } as unknown as AttendanceRegistrationPreStaging;
} }
} }

View File

@ -36,6 +36,7 @@ export class DyflexisEmployeeTransformer {
contract_end, contract_end,
contract_hours_week, contract_hours_week,
contract_salary_hour, contract_salary_hour,
contract_type_name,
} = dyflexisEmployeeParsed; } = dyflexisEmployeeParsed;
return { return {
@ -46,6 +47,7 @@ export class DyflexisEmployeeTransformer {
contractEnd: contract_end, contractEnd: contract_end,
contractHoursWeek: contract_hours_week, contractHoursWeek: contract_hours_week,
contractSalaryHour: contract_salary_hour, contractSalaryHour: contract_salary_hour,
contractTypeName: contract_type_name,
} as unknown as EmployeeDyflexisPreStaging; } as unknown as EmployeeDyflexisPreStaging;
} }
} }

View File

@ -18,7 +18,7 @@ export class PlannedTimeTransformer {
// firstname: faker.person.firstName(), // firstname: faker.person.firstName(),
// surname: faker.person.lastName(), // surname: faker.person.lastName(),
...transformProperties(parseInteger)( ...transformProperties(parseInteger)(
pick(['id', 'user_id'])(plannedTimeSource), pick(['id', 'user_id', 'pauze', 'duration'])(plannedTimeSource),
), ),
...transformProperties(parseCETDateTime('yyyy-MM-dd HH:mm:ss'))( ...transformProperties(parseCETDateTime('yyyy-MM-dd HH:mm:ss'))(
pick(['start_date', 'end_date'])(plannedTimeSource), pick(['start_date', 'end_date'])(plannedTimeSource),
@ -36,6 +36,8 @@ export class PlannedTimeTransformer {
department_name, department_name,
start_date, start_date,
end_date, end_date,
pauze,
duration,
deleted, deleted,
mark, mark,
} = plannedTimeParsed; } = plannedTimeParsed;
@ -44,6 +46,8 @@ export class PlannedTimeTransformer {
id, id,
firstname, firstname,
surname, surname,
pause: pauze,
duration,
deleted, deleted,
mark, mark,
employeeId: user_id, employeeId: user_id,

View File

@ -63,7 +63,7 @@ export class TourTransformer {
) )
: undefined), : undefined),
...transformProperties(parseBoolean)( ...transformProperties(parseBoolean)(
pick(['infektion', 'gefahren'])(tourSource), pick(['infektion', 'gefahren', 'overRTW', 'schwerlast'])(tourSource),
), ),
}; };
@ -111,6 +111,9 @@ export class TourTransformer {
gesamtPreis, gesamtPreis,
abrDate, abrDate,
abrNum, abrNum,
overRTW,
companyName,
schwerlast,
createTime, createTime,
lastUpdate, lastUpdate,
} = tourParsed; } = tourParsed;
@ -160,6 +163,9 @@ export class TourTransformer {
revenueDispoLive: gesamtPreis, revenueDispoLive: gesamtPreis,
billDate: abrDate, billDate: abrDate,
billNumber: abrNum, billNumber: abrNum,
overRTW,
companyName,
schwerlast,
createdAt: createTime, createdAt: createTime,
updatedAt: lastUpdate, updatedAt: lastUpdate,
revenue: 0, revenue: 0,

View File

@ -15,7 +15,7 @@ export class WorkTimeTransformer {
// firstname: faker.person.firstName(), // firstname: faker.person.firstName(),
// surname: faker.person.lastName(), // surname: faker.person.lastName(),
...transformProperties(parseInteger)( ...transformProperties(parseInteger)(
pick(['id', 'user_id'])(workTimeSource), pick(['id', 'user_id', 'pauze', 'duration'])(workTimeSource),
), ),
...transformProperties(parseCETDateTime('yyyy-MM-dd HH:mm:ss'))( ...transformProperties(parseCETDateTime('yyyy-MM-dd HH:mm:ss'))(
pick(['start_date', 'end_date'])(workTimeSource), pick(['start_date', 'end_date'])(workTimeSource),
@ -30,12 +30,16 @@ export class WorkTimeTransformer {
department_name, department_name,
start_date, start_date,
end_date, end_date,
pauze,
duration,
} = workTimeParsed; } = workTimeParsed;
return { return {
id, id,
firstname, firstname,
surname, surname,
duration,
pause: pauze,
employeeId: user_id, employeeId: user_id,
departmentName: department_name, departmentName: department_name,
startDate: start_date, startDate: start_date,

View File

@ -143,7 +143,8 @@ export class TicketLifecycleService {
({ ordinanceType }) => ({ ordinanceType }) =>
ordinanceType.indexOf('BTW') > -1 || ordinanceType.indexOf('BTW') > -1 ||
ordinanceType.indexOf('TSW') > -1, ordinanceType.indexOf('TSW') > -1,
) ) &&
ticket.currentState != TicketValidationState.TD_UNCERTAIN
) { ) {
await this.ticketService.updateTicket(ticket.id, { await this.ticketService.updateTicket(ticket.id, {
errors, errors,