init data-connector
This commit is contained in:
parent
52c1a14609
commit
46631db5b2
|
|
@ -1,14 +1,27 @@
|
||||||
FROM node:20.15.1-alpine as builder
|
FROM node:20.15.1-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app/builder
|
WORKDIR /app/builder
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm install \
|
RUN npm install
|
||||||
&& npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20.15.1-alpine
|
FROM node:20.15.1-alpine
|
||||||
|
|
||||||
|
ENV OTEL_TRACES_EXPORTER="otlp"
|
||||||
|
ENV OTEL_METRICS_EXPORTER="otlp"
|
||||||
|
ENV OTEL_LOGS_EXPORTER="otlp"
|
||||||
|
ENV NODE_OPTIONS="--require @opentelemetry/auto-instrumentations-node/register"
|
||||||
|
|
||||||
|
# To setup your own Service Instance ID
|
||||||
|
ENV OTEL_RESOURCE_ATTRIBUTES="service.instance.id=avicenna.data-connector"
|
||||||
|
# ENV OTEL_NODE_RESOURCE_DETECTORS="env,host,os"
|
||||||
|
|
||||||
|
# To have a Service Instance ID generated using a random UUID
|
||||||
|
# Requires auto-instrumentations-node version 0.46.0+
|
||||||
|
ENV OTEL_NODE_RESOURCE_DETECTORS="env,host,os,serviceinstance,container"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/builder/node_modules ./node_modules
|
COPY --from=builder /app/builder/node_modules ./node_modules
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ services:
|
||||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||||
POSTGRES_USER: ${DATABASE_USERNAME}
|
POSTGRES_USER: ${DATABASE_USERNAME}
|
||||||
POSTGRES_DB: ${DATABASE_DATABASE}
|
POSTGRES_DB: ${DATABASE_DATABASE}
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: password
|
||||||
PG_DATA: /var/lib/postgresql/data
|
PG_DATA: /var/lib/postgresql/data
|
||||||
volumes:
|
volumes:
|
||||||
- ./.local/pgdata:/var/lib/postgresql/data
|
- ./.local/pgdata:/var/lib/postgresql/data
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
"**/instrument.js"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,16 +23,30 @@
|
||||||
"seed": "ts-node prisma/seed/seed.ts"
|
"seed": "ts-node prisma/seed/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apollo/server": "^4.10.5",
|
||||||
|
"@faker-js/faker": "^9.0.1",
|
||||||
|
"@nestjs/apollo": "^12.2.0",
|
||||||
"@nestjs/axios": "^3.0.2",
|
"@nestjs/axios": "^3.0.2",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.2.3",
|
"@nestjs/config": "^3.2.3",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/event-emitter": "^2.0.4",
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
|
"@nestjs/graphql": "^12.2.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"@nestjs/schedule": "^4.1.0",
|
"@nestjs/schedule": "^4.1.0",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.56.1",
|
||||||
|
"@opentelemetry/exporter-metrics-otlp-proto": "^0.200.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.200.0",
|
||||||
|
"@opentelemetry/resources": "^2.0.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.200.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||||
"@prisma/client": "^5.16.2",
|
"@prisma/client": "^5.16.2",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"date-fns-tz": "^3.1.3",
|
"date-fns-tz": "^3.1.3",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
|
"graphql-subscriptions": "^2.0.0",
|
||||||
|
"graphql-ws": "^5.16.0",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"prisma-kysely": "^1.8.0",
|
"prisma-kysely": "^1.8.0",
|
||||||
|
|
@ -61,7 +75,7 @@
|
||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.1.3"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ export const KTW_AOK_TARIFFS = [
|
||||||
'1/KTW-04',
|
'1/KTW-04',
|
||||||
'1/KTW-05',
|
'1/KTW-05',
|
||||||
'1/KTW-06',
|
'1/KTW-06',
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Leitstellenmanagement',
|
description: 'Leitstellenmanagement',
|
||||||
|
|
@ -241,7 +241,7 @@ export const KTW_AOK_TARIFFS = [
|
||||||
'1/KTW-04',
|
'1/KTW-04',
|
||||||
'1/KTW-05-E',
|
'1/KTW-05-E',
|
||||||
'1/KTW-06',
|
'1/KTW-06',
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Leitstellenmanagement',
|
description: 'Leitstellenmanagement',
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ export const KTW_VDEK_TARIFFS = [
|
||||||
{
|
{
|
||||||
description: 'VDEK KTW',
|
description: 'VDEK KTW',
|
||||||
validFrom: startOfDay(new Date('2023-05-01')),
|
validFrom: startOfDay(new Date('2023-05-01')),
|
||||||
validTo: null,
|
validTo: endOfDay(new Date('2024-04-31')),
|
||||||
costTypes: [
|
costTypes: [
|
||||||
{
|
{
|
||||||
description: 'Grundpauschale je Fahrt',
|
description: 'Grundpauschale je Fahrt',
|
||||||
|
|
@ -278,4 +278,95 @@ export const KTW_VDEK_TARIFFS = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: 'VDEK KTW',
|
||||||
|
validFrom: startOfDay(new Date('2024-05-01')),
|
||||||
|
validTo: null,
|
||||||
|
costTypes: [
|
||||||
|
{
|
||||||
|
description: 'Grundpauschale je Fahrt',
|
||||||
|
variant: CostTypeVariant.GRUNDPREIS,
|
||||||
|
costAmount: 7634,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'QMS Zuschlag',
|
||||||
|
variant: CostTypeVariant.GRUNDPREIS,
|
||||||
|
costAmount: 3008,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Modernes Einsatzfahrzeug',
|
||||||
|
variant: CostTypeVariant.GRUNDPREIS,
|
||||||
|
costAmount: 1885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Leitstellenmanagement',
|
||||||
|
variant: CostTypeVariant.GRUNDPREIS,
|
||||||
|
costAmount: 699,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'ab dem 17. km',
|
||||||
|
variant: CostTypeVariant.KILOMETERZUSCHLAG,
|
||||||
|
costAmount: 385,
|
||||||
|
kmInclusive: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Nachtzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 5970,
|
||||||
|
timeRange: '19:00-19:59',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Nachtzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 6924,
|
||||||
|
timeRange: '20:00-21:59',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Nachtzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 7502,
|
||||||
|
timeRange: '22:00-04:59',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Nachtzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 5970,
|
||||||
|
timeRange: '05:00-06:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Wochenendzuschlag',
|
||||||
|
variant: CostTypeVariant.WOCHENENDZUSCHLAG,
|
||||||
|
costAmount: 6697,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Feiertagszuschlag',
|
||||||
|
variant: CostTypeVariant.FEIERTAGSZUSCHLAG,
|
||||||
|
costAmount: 6697,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Desinfektionszuschlag 2.A',
|
||||||
|
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
|
||||||
|
costAmount: 4457,
|
||||||
|
infectionType: '2.A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Desinfektionszuschlag 2.B',
|
||||||
|
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
|
||||||
|
costAmount: 18265,
|
||||||
|
infectionType: '2.B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Desinfektionszuschlag 3.1',
|
||||||
|
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
|
||||||
|
costAmount: 21121,
|
||||||
|
infectionType: '3.1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Desinfektionszuschlag 3.2',
|
||||||
|
variant: CostTypeVariant.DESINFEKTIONSZUSCHLAG,
|
||||||
|
costAmount: 21121,
|
||||||
|
infectionType: '3.2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export const TSW_DAK_VDEK_TARIFFS = [
|
||||||
{
|
{
|
||||||
description: 'DAK + VDEK TSW',
|
description: 'DAK + VDEK TSW',
|
||||||
validFrom: startOfDay(new Date('2023-06-01')),
|
validFrom: startOfDay(new Date('2023-06-01')),
|
||||||
validTo: null,
|
validTo: endOfDay(new Date('2024-08-31')),
|
||||||
costTypes: [
|
costTypes: [
|
||||||
{
|
{
|
||||||
description: 'Grundpauschale je Fahrt (TSW 2)',
|
description: 'Grundpauschale je Fahrt (TSW 2)',
|
||||||
|
|
@ -55,4 +55,44 @@ export const TSW_DAK_VDEK_TARIFFS = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: 'DAK + VDEK TSW',
|
||||||
|
validFrom: startOfDay(new Date('2024-09-01')),
|
||||||
|
validTo: null,
|
||||||
|
costTypes: [
|
||||||
|
{
|
||||||
|
description: 'Grundpauschale je Fahrt (TS 2)',
|
||||||
|
variant: CostTypeVariant.TSWDIFFERENZIERUNG,
|
||||||
|
costAmount: 6434,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Grundpauschale je Fahrt (TS 1)',
|
||||||
|
variant: CostTypeVariant.TSWDIFFERENZIERUNG,
|
||||||
|
costAmount: 4793,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Grundpauschale je Fahrt (TS+R)',
|
||||||
|
variant: CostTypeVariant.TSWDIFFERENZIERUNG,
|
||||||
|
costAmount: 5793,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Nachtzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 407,
|
||||||
|
timeRange: '05:00-07:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Fallmanagementzuschlag',
|
||||||
|
variant: CostTypeVariant.NACHTZUSCHLAG,
|
||||||
|
costAmount: 750,
|
||||||
|
timeRange: '14:30-23:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'ab dem 22. km',
|
||||||
|
variant: CostTypeVariant.KILOMETERZUSCHLAG,
|
||||||
|
costAmount: 180,
|
||||||
|
kmInclusive: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ImporterService } from './modules/feat-importers/importer.service';
|
import { PrismaService } from './core/database/prisma.service';
|
||||||
|
import { AnomaliesService } from './modules/feat-anomalies/anomalies.service';
|
||||||
|
import { CalculationService } from './modules/feat-calculation/calculation.service';
|
||||||
|
import { ImporterService } from './modules/feat-etl/feat-importers/importer.service';
|
||||||
|
import { TicketService } from './modules/feat-tickets/tickets.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly importer: ImporterService) {}
|
constructor(
|
||||||
|
private readonly importer: ImporterService,
|
||||||
|
private readonly anomaliesService: AnomaliesService,
|
||||||
|
private readonly ticketService: TicketService,
|
||||||
|
private readonly calculationService: CalculationService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('etl')
|
@Get('etl')
|
||||||
etl(@Query('from') from: string, @Query('to') to: string) {
|
etl(@Query('from') from: string, @Query('to') to: string) {
|
||||||
|
|
@ -19,9 +29,124 @@ export class AppController {
|
||||||
return this.importer.runImport(interval);
|
return this.importer.runImport(interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call Extractors
|
@Get('tickets')
|
||||||
|
tickets(@Query('from') from: string, @Query('to') to: string) {
|
||||||
|
if (!from || !to) {
|
||||||
|
return {
|
||||||
|
error: 'from and to must be defined',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const interval = {
|
||||||
|
start: new Date(from),
|
||||||
|
end: new Date(to),
|
||||||
|
};
|
||||||
|
return this.ticketService.createTickets(interval);
|
||||||
|
}
|
||||||
|
|
||||||
// Call Transformers
|
@Get('anomalies')
|
||||||
|
anomalies(@Query('from') from: string, @Query('to') to: string) {
|
||||||
|
if (!from || !to) {
|
||||||
|
return {
|
||||||
|
error: 'from and to must be defined',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const interval = {
|
||||||
|
start: new Date(from),
|
||||||
|
end: new Date(to),
|
||||||
|
};
|
||||||
|
return this.anomaliesService
|
||||||
|
.detectAnomalies(interval)
|
||||||
|
.then(() => this.anomaliesService.validateAnomalies());
|
||||||
|
}
|
||||||
|
|
||||||
// Call Loaders
|
@Get('tour-calculation')
|
||||||
|
async tourCalculation(@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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
carName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Storno',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Löschen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Abgabe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: {
|
||||||
|
not: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
patientId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const toursWithRevenue = [];
|
||||||
|
for (const tour of tours) {
|
||||||
|
toursWithRevenue.push({
|
||||||
|
...tour,
|
||||||
|
revenue: await this.calculationService.calculateTourRevenue(tour),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(this.calculationService.getHealthInsurancesWithNoTenant());
|
||||||
|
return toursWithRevenue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,54 @@
|
||||||
|
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { PostgresDialect } from 'kysely';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Pool } from 'pg';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
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 { ConfigModule } from './core/config/config.module';
|
||||||
import { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
|
||||||
import { DatabaseModule } from './core/database/database.module';
|
import { DatabaseModule } from './core/database/database.module';
|
||||||
import { PostgresDialect } from 'kysely';
|
import { PrismaModule } from './core/database/prisma.module';
|
||||||
import { Pool } from 'pg';
|
import { ApplicationAccountingModule } from './modules/app-accounting/app-accounting.module';
|
||||||
|
import { ApplicationControlCenterModule } from './modules/app-control-center/app-control-center.module';
|
||||||
|
import { ApplicationManagerModule } from './modules/app-manager/app-manager.module';
|
||||||
|
import { ApplicationTicketSystemModule } from './modules/app-ticket-system/app-ticket-system.module';
|
||||||
|
import { AnomalyDetectionModule } from './modules/feat-anomalies/anomaly-detection.module';
|
||||||
|
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
|
||||||
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
||||||
|
import { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||||
|
import { FeatureCalculationModule } from './modules/feat-calculation/feat-calculation.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
|
|
||||||
|
// DatabaseModule.forRootAsync({
|
||||||
|
// isGlobal: true,
|
||||||
|
// inject: [ConfigService],
|
||||||
|
// useFactory(config: ConfigService) {
|
||||||
|
// return {
|
||||||
|
// config: {
|
||||||
|
// dialect: new PostgresDialect({
|
||||||
|
// pool: new Pool({
|
||||||
|
// host: config.databaseHost,
|
||||||
|
// port: config.databasePort,
|
||||||
|
// database: config.databaseName,
|
||||||
|
// user: config.databaseUser,
|
||||||
|
// password: config.databasePassword,
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// },
|
||||||
|
// }),
|
||||||
DatabaseModule.forRoot({
|
DatabaseModule.forRoot({
|
||||||
|
// isGlobal: true,
|
||||||
dialect: new PostgresDialect({
|
dialect: new PostgresDialect({
|
||||||
pool: new Pool({
|
pool: new Pool({
|
||||||
host: 'localhost',
|
host: 'db',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
database: 'avicenna',
|
database: 'avicenna',
|
||||||
user: 'johndoe',
|
user: 'johndoe',
|
||||||
|
|
@ -26,12 +56,38 @@ import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
// DatabaseModule.forRoot({
|
||||||
|
// dialect: new PostgresDialect({
|
||||||
|
// pool: new Pool({
|
||||||
|
// host: 'localhost',
|
||||||
|
// port: 54322,
|
||||||
|
// database: 'postgres',
|
||||||
|
// user: 'postgres',
|
||||||
|
// password: 'postgres',
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
PrismaModule.forRoot(),
|
PrismaModule.forRoot(),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
FeatureImporterModule,
|
GraphQLModule.forRoot<ApolloDriverConfig>({
|
||||||
|
driver: ApolloDriver,
|
||||||
|
playground: true,
|
||||||
|
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
|
||||||
|
sortSchema: true,
|
||||||
|
subscriptions: {
|
||||||
|
'graphql-ws': true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
FeatureEtlModule,
|
||||||
FeatReportsModule,
|
FeatReportsModule,
|
||||||
|
FeatureCalculationModule,
|
||||||
TicketSystemModule,
|
TicketSystemModule,
|
||||||
|
AnomalyDetectionModule,
|
||||||
|
ApplicationManagerModule,
|
||||||
|
ApplicationAccountingModule,
|
||||||
|
ApplicationControlCenterModule,
|
||||||
|
ApplicationTicketSystemModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
startOfDay,
|
startOfDay,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { concatMap, from, lastValueFrom, switchMap, take, tap } from 'rxjs';
|
import { concatMap, from, lastValueFrom, switchMap, take, tap } from 'rxjs';
|
||||||
import { ImporterService } from './modules/feat-importers/importer.service';
|
import { ImporterService } from './modules/feat-etl/feat-importers/importer.service';
|
||||||
import { TicketService } from './modules/feat-tickets/tickets.service';
|
import { TicketService } from './modules/feat-tickets/tickets.service';
|
||||||
import { chunk } from './utils';
|
import { chunk } from './utils';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { ObjectType, Field } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ObjectType('Interval')
|
||||||
|
export class IntervalObjectType {
|
||||||
|
@Field()
|
||||||
|
start: Date;
|
||||||
|
@Field()
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Type } from '@nestjs/common';
|
||||||
|
import { ArgsType, Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class PaginationArgs {
|
||||||
|
@Field(() => Int)
|
||||||
|
take = 10;
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// interface IEdgeType<T> {
|
||||||
|
// cursor: string;
|
||||||
|
// node: T;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export interface IPaginatedType<T> {
|
||||||
|
// edges: IEdgeType<T>[];
|
||||||
|
nodes: T[];
|
||||||
|
prevCursor: string;
|
||||||
|
endCursor: string;
|
||||||
|
totalCount: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Paginated<T>(classRef: Type<T>): Type<IPaginatedType<T>> {
|
||||||
|
// @ObjectType(`${classRef.name}Edge`)
|
||||||
|
// abstract class EdgeType {
|
||||||
|
// @Field(() => String)
|
||||||
|
// cursor: string;
|
||||||
|
// @Field(() => classRef)
|
||||||
|
// node: T;
|
||||||
|
// }
|
||||||
|
|
||||||
|
@ObjectType({ isAbstract: true })
|
||||||
|
abstract class PaginatedType implements IPaginatedType<T> {
|
||||||
|
// @Field(() => [EdgeType], { nullable: true })
|
||||||
|
// edges: EdgeType[];
|
||||||
|
|
||||||
|
@Field(() => [classRef])
|
||||||
|
nodes: T[];
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
prevCursor: string;
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
endCursor: string;
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
@Field()
|
||||||
|
hasNextPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PaginatedType as Type<IPaginatedType<T>>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function encodeCursor(cursor: string | number): string {
|
||||||
|
return Buffer.from(`${cursor}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeCursor(cursor: string): string {
|
||||||
|
return Buffer.from(cursor, 'base64').toString('ascii');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeOffsetCursor(cursor: string): number {
|
||||||
|
return Number.parseInt(decodeCursor(cursor));
|
||||||
|
}
|
||||||
|
|
@ -24,4 +24,19 @@ export class ConfigService extends NestConfigService {
|
||||||
dyflexisV2ApiKey(): string {
|
dyflexisV2ApiKey(): string {
|
||||||
return this.get('DYFLEXIS_V2_API_KEY');
|
return this.get('DYFLEXIS_V2_API_KEY');
|
||||||
}
|
}
|
||||||
|
databasePort(): string {
|
||||||
|
return this.get('DATABASE_PORT');
|
||||||
|
}
|
||||||
|
databaseHost(): string {
|
||||||
|
return this.get('DATABASE_HOST');
|
||||||
|
}
|
||||||
|
databaseUser(): string {
|
||||||
|
return this.get('DATABASE_USERNAME');
|
||||||
|
}
|
||||||
|
databasePassword(): string {
|
||||||
|
return this.get('DATABASE_PASSWORD');
|
||||||
|
}
|
||||||
|
databaseName(): string {
|
||||||
|
return this.get('DATABASE_DATABASE');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { ConfigurableModuleBuilder } from '@nestjs/common';
|
||||||
|
import { KyselyModuleAsyncOptions } from './database.module';
|
||||||
|
|
||||||
|
export const {
|
||||||
|
ConfigurableModuleClass,
|
||||||
|
MODULE_OPTIONS_TOKEN,
|
||||||
|
ASYNC_OPTIONS_TYPE,
|
||||||
|
OPTIONS_TYPE,
|
||||||
|
} = new ConfigurableModuleBuilder<KyselyModuleAsyncOptions>()
|
||||||
|
.setClassMethodName('forRoot')
|
||||||
|
.setExtras<{ isGlobal?: boolean }>(
|
||||||
|
{ isGlobal: false },
|
||||||
|
(definition, extras) => ({ ...definition, global: extras.isGlobal }),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
@ -1,8 +1,33 @@
|
||||||
import { DynamicModule, Global, Module, Provider } from '@nestjs/common';
|
import {
|
||||||
|
DynamicModule,
|
||||||
|
Global,
|
||||||
|
Module,
|
||||||
|
ModuleMetadata,
|
||||||
|
Provider,
|
||||||
|
Type,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { KyselyConfig } from 'kysely';
|
import { KyselyConfig } from 'kysely';
|
||||||
import { createKyselyProvider } from './kysely-client.factory';
|
import { createKyselyProvider } from './kysely-client.factory';
|
||||||
|
|
||||||
export const KYSELY_CLIENT_PROVIDER_TOKEN = 'KyselyClientProviderToken';
|
export const KYSELY_CLIENT_PROVIDER_TOKEN = 'KyselyClientProviderToken';
|
||||||
|
export const KYSELY_CONFIG_TOKEN = 'KYSELY_CONFIG_TOKEN';
|
||||||
|
|
||||||
|
export interface KyselyConfigFactory {
|
||||||
|
createKyselyConfig(): Promise<KyselyConfig> | KyselyConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KyselyModuleAsyncOptions
|
||||||
|
extends Pick<ModuleMetadata, 'imports'> {
|
||||||
|
isGlobal?: boolean;
|
||||||
|
useExisting?: Type<KyselyConfigFactory>;
|
||||||
|
useClass?: Type<KyselyConfigFactory>;
|
||||||
|
useFactory?: (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
...args: any[]
|
||||||
|
) => KyselyConfig;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
inject?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({})
|
@Module({})
|
||||||
|
|
@ -16,4 +41,83 @@ export class DatabaseModule {
|
||||||
providers: [provider],
|
providers: [provider],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static forRootAsync(options: KyselyModuleAsyncOptions): DynamicModule {
|
||||||
|
return {
|
||||||
|
global: options.isGlobal,
|
||||||
|
module: DatabaseModule,
|
||||||
|
imports: options.imports || [],
|
||||||
|
providers: this.createAsyncProviders(options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createAsyncProviders(
|
||||||
|
options: KyselyModuleAsyncOptions,
|
||||||
|
): Provider[] {
|
||||||
|
if (options.useExisting || options.useFactory) {
|
||||||
|
return this.createAsyncOptionsProvider(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.useClass) {
|
||||||
|
return [
|
||||||
|
...this.createAsyncOptionsProvider(options),
|
||||||
|
{
|
||||||
|
provide: options.useClass,
|
||||||
|
useClass: options.useClass,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...this.createAsyncOptionsProvider(options)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static createAsyncOptionsProvider(
|
||||||
|
options: KyselyModuleAsyncOptions,
|
||||||
|
): Provider[] {
|
||||||
|
if (options.useFactory) {
|
||||||
|
const config = options.useFactory(options.inject);
|
||||||
|
return [
|
||||||
|
// {
|
||||||
|
// provide: KYSELY_CONFIG_TOKEN,
|
||||||
|
// useFactory: options.useFactory,
|
||||||
|
// inject: options.inject || [],
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
provide: KYSELY_CLIENT_PROVIDER_TOKEN,
|
||||||
|
useExisting: createKyselyProvider(config)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.useExisting) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provide: KYSELY_CONFIG_TOKEN,
|
||||||
|
useFactory: async (optionsFactory: KyselyConfigFactory) =>
|
||||||
|
await optionsFactory.createKyselyConfig(),
|
||||||
|
inject: [options.useExisting],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.useClass) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provide: KYSELY_CONFIG_TOKEN,
|
||||||
|
useFactory: async (optionsFactory: KyselyConfigFactory) =>
|
||||||
|
await optionsFactory.createKyselyConfig(),
|
||||||
|
inject: [options.useClass],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
provide: KYSELY_CONFIG_TOKEN,
|
||||||
|
useFactory: async (optionsFactory: KyselyConfigFactory) =>
|
||||||
|
await optionsFactory.createKyselyConfig(),
|
||||||
|
inject: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { ModuleMetadata, Type } from "@nestjs/common";
|
import { ModuleMetadata, Type } from '@nestjs/common';
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
export interface PrismaModuleOptions {
|
export interface PrismaModuleOptions {
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||||
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
input AccountingFilterArgs {
|
||||||
|
deviation: [String!]
|
||||||
|
missing: [String!]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountingState {
|
||||||
|
Billable
|
||||||
|
Billed
|
||||||
|
PreparedForBilling
|
||||||
|
}
|
||||||
|
|
||||||
|
type Anomaly {
|
||||||
|
createdAt: DateTime!
|
||||||
|
description: String!
|
||||||
|
groupDescription: String!
|
||||||
|
groupKey: String!
|
||||||
|
id: String!
|
||||||
|
manuallyResolved: Boolean
|
||||||
|
resolvedAt: DateTime
|
||||||
|
sleepSince: DateTime
|
||||||
|
sleepTimer: Int!
|
||||||
|
solution: [SolutionTarget!]!
|
||||||
|
tour: Tour
|
||||||
|
type: String!
|
||||||
|
workedOnBy: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnomalyGroup {
|
||||||
|
anomalies: [Anomaly!]!
|
||||||
|
key: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApprovalInfo {
|
||||||
|
answeredFrom: String
|
||||||
|
isNeeded: Boolean
|
||||||
|
location: String
|
||||||
|
whoRequested: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input ApprovalInfoArgs {
|
||||||
|
answeredFrom: String
|
||||||
|
isNeeded: Boolean
|
||||||
|
location: String
|
||||||
|
whoRequested: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApprovalState {
|
||||||
|
APPROVED
|
||||||
|
FREE
|
||||||
|
REQUESTED
|
||||||
|
REQUIRED
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||||
|
"""
|
||||||
|
scalar DateTime
|
||||||
|
|
||||||
|
type DocumentInfo {
|
||||||
|
approval: ApprovalInfo!
|
||||||
|
tdLocation: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Interval {
|
||||||
|
end: DateTime!
|
||||||
|
start: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type KPI {
|
||||||
|
meta: KPIMetadata!
|
||||||
|
value: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
type KPIByCar {
|
||||||
|
carName: String!
|
||||||
|
kpi: KPI!
|
||||||
|
}
|
||||||
|
|
||||||
|
type KPIMetadata {
|
||||||
|
availableTime: Float!
|
||||||
|
baseValue: Float!
|
||||||
|
breakEven: Float!
|
||||||
|
tourAmount: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
acceptAnomaly(id: String!): Anomaly!
|
||||||
|
setAnomalyToSleep(id: String!): Anomaly!
|
||||||
|
setDocumentInfo(approval: ApprovalInfoArgs, tdLocation: String, ticketId: String!): Ticket!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedAnomaly {
|
||||||
|
endCursor: String!
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
nodes: [Anomaly!]!
|
||||||
|
prevCursor: String!
|
||||||
|
totalCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedAnomalyGroup {
|
||||||
|
endCursor: String!
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
nodes: [AnomalyGroup!]!
|
||||||
|
prevCursor: String!
|
||||||
|
totalCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedTicket {
|
||||||
|
endCursor: String!
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
nodes: [Ticket!]!
|
||||||
|
prevCursor: String!
|
||||||
|
totalCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginatedTour {
|
||||||
|
endCursor: String!
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
nodes: [Tour!]!
|
||||||
|
prevCursor: String!
|
||||||
|
totalCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
accountingStateMeta(state: AccountingState!): TourStateMeta!
|
||||||
|
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
|
||||||
|
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
|
||||||
|
anomaliesProto: [Anomaly!]!
|
||||||
|
anomaly(id: String!): Anomaly!
|
||||||
|
groupedAnomalies(cursor: String, take: Int! = 10): PaginatedAnomalyGroup!
|
||||||
|
searchTickets(query: String!): [Ticket!]!
|
||||||
|
ticket(id: String!): Ticket!
|
||||||
|
ticketStateKeyFigures(state: TicketValidationState!): TicketStateKeyFigures!
|
||||||
|
ticketStateMeta(state: TicketValidationState!): TicketStateMeta!
|
||||||
|
tickets(cursor: String, filters: TicketFilterArgs, state: TicketValidationState!, take: Int! = 10): PaginatedTicket!
|
||||||
|
timePerTourMetricForDay(day: DateTime!, ordinanceType: String!): [TimePerTourMetric!]!
|
||||||
|
timePerTourMetricForMonth(day: DateTime!, ordinanceType: String!): [TimePerTourMetric!]!
|
||||||
|
toursPerTimeKPI(ordinanceTypes: String!): [TimePerTourKPISummary!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SolutionTarget {
|
||||||
|
anomalyId: String!
|
||||||
|
id: String!
|
||||||
|
name: String!
|
||||||
|
steps: [SolutionTargetStep!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SolutionTargetStep {
|
||||||
|
description: String!
|
||||||
|
id: String!
|
||||||
|
solutionTargetId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription {
|
||||||
|
ticketsUpdated: TicketsUpdated!
|
||||||
|
timePerTourUpdated: TimePerTourUpdate!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ticket {
|
||||||
|
approvalState: ApprovalState
|
||||||
|
createdAt: DateTime!
|
||||||
|
currentState: TicketValidationState!
|
||||||
|
documentInfo: DocumentInfo
|
||||||
|
errors: [String!]!
|
||||||
|
id: String!
|
||||||
|
isUrgent: Boolean!
|
||||||
|
notes: [String!]!
|
||||||
|
tours: [Tour!]!
|
||||||
|
urgency: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
input TicketFilterArgs {
|
||||||
|
approvalStates: [ApprovalState!]
|
||||||
|
documentInfo: [String!]
|
||||||
|
errors: [String!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketStateKeyFigures {
|
||||||
|
dwellTime: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketStateMeta {
|
||||||
|
approvalNotCompanyCount: Int
|
||||||
|
approvalRequiredCount: Int
|
||||||
|
approvalUnknownCount: Int
|
||||||
|
count: Int!
|
||||||
|
tdNotCompanyCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TicketValidationState {
|
||||||
|
ARCHIVED
|
||||||
|
CREATED
|
||||||
|
DOCUMENTS_MISSING
|
||||||
|
TD_UNCERTAIN
|
||||||
|
USER_INTERACTION_REQUIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketsUpdated {
|
||||||
|
count: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimePerTourKPISummary {
|
||||||
|
cars: [KPIByCar!]!
|
||||||
|
interval: Interval!
|
||||||
|
kpi: KPI!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimePerTourMetric {
|
||||||
|
availableTime: Float!
|
||||||
|
averageRevenue: Float!
|
||||||
|
breakEven: Float!
|
||||||
|
categoryAmounts: [TimePerTourMetricCategoryAmount!]!
|
||||||
|
consumptionCost: Float!
|
||||||
|
forecast: Float!
|
||||||
|
forecastRevenue: Float!
|
||||||
|
interval: Interval!
|
||||||
|
personnelCost: Float!
|
||||||
|
revenue: Float!
|
||||||
|
tourAmount: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimePerTourMetricCategoryAmount {
|
||||||
|
amount: Int!
|
||||||
|
category: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimePerTourUpdate {
|
||||||
|
job: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tour {
|
||||||
|
billDate: DateTime
|
||||||
|
billNumber: String
|
||||||
|
carName: String
|
||||||
|
category: String
|
||||||
|
check: Int!
|
||||||
|
codriverId: String
|
||||||
|
codriverName: String
|
||||||
|
consumptionCosts: Float!
|
||||||
|
createdAt: DateTime
|
||||||
|
deletedAt: DateTime
|
||||||
|
direction: String!
|
||||||
|
done: Boolean!
|
||||||
|
driverId: String
|
||||||
|
driverName: String
|
||||||
|
empty: String
|
||||||
|
hasInfection: Boolean
|
||||||
|
healthInsurance: String
|
||||||
|
healthInsuranceNumber: String
|
||||||
|
id: String!
|
||||||
|
infectionName: String
|
||||||
|
occupiedKm: Float!
|
||||||
|
operationId: String!
|
||||||
|
ordinanceType: String
|
||||||
|
patientCity: String
|
||||||
|
patientId: String
|
||||||
|
patientName: String
|
||||||
|
patientStreet: String
|
||||||
|
patientSurname: String
|
||||||
|
patientZip: String
|
||||||
|
rangeEndDate: DateTime
|
||||||
|
revenue: Float!
|
||||||
|
revenueDeviation: Float!
|
||||||
|
revenueDispoLive: Float!
|
||||||
|
startBegin: String
|
||||||
|
startCity: String
|
||||||
|
startDate: DateTime
|
||||||
|
startEnd: String
|
||||||
|
startInstitution: String
|
||||||
|
startStreet: String
|
||||||
|
startZip: String
|
||||||
|
target: String
|
||||||
|
targetBegin: String
|
||||||
|
targetCity: String
|
||||||
|
targetInstitution: String
|
||||||
|
targetStreet: String
|
||||||
|
targetZip: String
|
||||||
|
ticket: Ticket
|
||||||
|
ticketId: String
|
||||||
|
totalKm: Float!
|
||||||
|
transportType: String
|
||||||
|
type: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type TourStateMeta {
|
||||||
|
count: Int!
|
||||||
|
revenueSum: Float
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
/*instrumentation.ts*/
|
||||||
|
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
||||||
|
const {
|
||||||
|
getNodeAutoInstrumentations,
|
||||||
|
} = require('@opentelemetry/auto-instrumentations-node');
|
||||||
|
const {
|
||||||
|
OTLPTraceExporter,
|
||||||
|
} = require('@opentelemetry/exporter-trace-otlp-proto'); // Annahme: gRPC/Proto Exporter für Backend
|
||||||
|
const {
|
||||||
|
OTLPMetricExporter,
|
||||||
|
} = require('@opentelemetry/exporter-metrics-otlp-proto'); // Annahme: gRPC/Proto Exporter für Backend
|
||||||
|
const { PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics');
|
||||||
|
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
||||||
|
const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
|
||||||
|
|
||||||
|
const otelEndpoint =
|
||||||
|
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ??
|
||||||
|
'http://monitoring_alloy:4318';
|
||||||
|
|
||||||
|
const sdk = new NodeSDK({
|
||||||
|
resource: resourceFromAttributes({
|
||||||
|
[ATTR_SERVICE_NAME]: 'avicenna.data-connector',
|
||||||
|
namespace: 'avicenna',
|
||||||
|
}),
|
||||||
|
traceExporter: new OTLPTraceExporter({
|
||||||
|
url: `${otelEndpoint}/v1/traces`,
|
||||||
|
}),
|
||||||
|
metricReader: new PeriodicExportingMetricReader({
|
||||||
|
exporter: new OTLPMetricExporter({
|
||||||
|
url: `${otelEndpoint}/v1/metrics`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
instrumentations: [getNodeAutoInstrumentations()],
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.start();
|
||||||
|
console.log('OTel SDK started');
|
||||||
|
|
@ -3,6 +3,8 @@ import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.enableCors();
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
ArgsType,
|
||||||
|
Field,
|
||||||
|
Float,
|
||||||
|
InputType,
|
||||||
|
Int,
|
||||||
|
ObjectType,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { PaginationArgs } from 'src/core/base/pagination.input';
|
||||||
|
import { AccountingFilter } from './accounting-filter';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class AccountingFilterArgs implements AccountingFilter {
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
deviation!: 'pos' | 'neg' | 'both'[];
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
missing?: 'information' | 'documents'[];
|
||||||
|
// @Field(() => IntervalObjectType, { nullable: true })
|
||||||
|
// dateRange?: Interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class AccountingTourArgs extends PaginationArgs {
|
||||||
|
@Field(() => AccountingState)
|
||||||
|
state!: AccountingState;
|
||||||
|
@Field(() => AccountingFilterArgs, { nullable: true })
|
||||||
|
filters!: AccountingFilterArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TourStateMeta')
|
||||||
|
export class TourStateMetaObjectType {
|
||||||
|
@Field(() => Int)
|
||||||
|
count!: number;
|
||||||
|
@Field(() => Float, { nullable: true })
|
||||||
|
revenueSum?: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Prisma, TicketValidationState } from '@prisma/client';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
|
||||||
|
export interface AccountingFilter {
|
||||||
|
deviation?: 'pos' | 'neg' | 'both'[];
|
||||||
|
missing?: 'information' | 'documents'[];
|
||||||
|
dateRange?: Interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrismaQueryFromAccountingFilters(
|
||||||
|
filter: AccountingFilter,
|
||||||
|
): Prisma.TourWhereInput[] {
|
||||||
|
const conditions: Prisma.TourWhereInput[] = [];
|
||||||
|
|
||||||
|
Object.entries(filter ?? {}).forEach(([property, args]) => {
|
||||||
|
switch (property) {
|
||||||
|
case 'deviation':
|
||||||
|
if (args.includes('pos'))
|
||||||
|
conditions.push({
|
||||||
|
revenueDeviation: { gt: 0 },
|
||||||
|
});
|
||||||
|
else if (args.includes('neg'))
|
||||||
|
conditions.push({
|
||||||
|
revenueDeviation: { lt: 0 },
|
||||||
|
});
|
||||||
|
else if (args.includes('both'))
|
||||||
|
conditions.push({
|
||||||
|
revenueDeviation: { not: 0 },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'missing':
|
||||||
|
if (args.includes('information'))
|
||||||
|
conditions.push({
|
||||||
|
Ticket: {
|
||||||
|
currentState: {
|
||||||
|
in: [
|
||||||
|
TicketValidationState.CREATED,
|
||||||
|
TicketValidationState.USER_INTERACTION_REQUIRED,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else if (args.includes('documents'))
|
||||||
|
conditions.push({
|
||||||
|
Ticket: {
|
||||||
|
currentState: {
|
||||||
|
in: [
|
||||||
|
TicketValidationState.TD_UNCERTAIN,
|
||||||
|
TicketValidationState.DOCUMENTS_MISSING,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'dateRange':
|
||||||
|
conditions.push({
|
||||||
|
AND: [
|
||||||
|
{ startDate: { gte: new Date(args.from) } },
|
||||||
|
{ startDate: { lt: new Date(args.to) } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum AccountingState {
|
||||||
|
Billable = 'Billable',
|
||||||
|
PreparedForBilling = 'PreparedForBilling',
|
||||||
|
Billed = 'Billed',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from 'src/core/database/prisma.service';
|
||||||
|
import { TourStateMetaObjectType } from './accounting-arg.types';
|
||||||
|
import {
|
||||||
|
AccountingFilter,
|
||||||
|
createPrismaQueryFromAccountingFilters,
|
||||||
|
} from './accounting-filter';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountingValidationRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findByState(
|
||||||
|
state: AccountingState,
|
||||||
|
filter: AccountingFilter,
|
||||||
|
take?: number,
|
||||||
|
skip?: number,
|
||||||
|
) {
|
||||||
|
const checkValue = this.getCheckValueForAccountingState(state);
|
||||||
|
const query: Prisma.TourFindManyArgs = {
|
||||||
|
...{
|
||||||
|
...(skip ? { skip, take: 10 } : {}),
|
||||||
|
...(take ? { take } : {}),
|
||||||
|
...(take && !skip ? { skip: 0 } : {}),
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
check: checkValue,
|
||||||
|
...(checkValue === 2 && !filter?.deviation
|
||||||
|
? { revenueDeviation: { not: 0 } }
|
||||||
|
: {}),
|
||||||
|
empty: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
ticketId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
AND: [
|
||||||
|
...createPrismaQueryFromAccountingFilters(filter),
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
carName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Storno',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Löschen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Abgabe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ patientId: null },
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tours, count] = await this.prisma.$transaction([
|
||||||
|
this.prisma.tour.findMany({ ...query }),
|
||||||
|
this.prisma.tour.count({ where: query.where }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
tours,
|
||||||
|
total: count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTourStateMeta(
|
||||||
|
state: AccountingState,
|
||||||
|
): Promise<TourStateMetaObjectType> {
|
||||||
|
const checkValue = this.getCheckValueForAccountingState(state);
|
||||||
|
const ticketWithCurrentStateAndTours: Prisma.TourWhereInput = {
|
||||||
|
check: checkValue,
|
||||||
|
...(checkValue === 2 ? { revenueDeviation: { not: 0 } } : {}),
|
||||||
|
empty: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
ticketId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
carName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Storno',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Löschen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Abgabe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ patientId: null },
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [count, revenueSum] = await this.prisma.$transaction([
|
||||||
|
this.prisma.tour.count({
|
||||||
|
where: { ...ticketWithCurrentStateAndTours },
|
||||||
|
}),
|
||||||
|
this.prisma.tour.aggregate({
|
||||||
|
_sum: {
|
||||||
|
revenue: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...ticketWithCurrentStateAndTours,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
revenueSum: revenueSum._sum.revenue ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
findTicketByTicketId(ticketId: string) {
|
||||||
|
return this.prisma.ticket.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ticketId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCheckValueForAccountingState(state: AccountingState): number {
|
||||||
|
switch (state) {
|
||||||
|
case AccountingState.Billable:
|
||||||
|
return 0;
|
||||||
|
case AccountingState.PreparedForBilling:
|
||||||
|
return 1;
|
||||||
|
case AccountingState.Billed:
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||||
|
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
|
||||||
|
import {
|
||||||
|
AccountingTourArgs,
|
||||||
|
TourStateMetaObjectType,
|
||||||
|
} from './accounting-arg.types';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { AccountingValidationService } from './accounting-validation.service';
|
||||||
|
import { PaginatedTourObjectType } from './accounting.object-type';
|
||||||
|
import { TourObjectType } from './tour.object-type';
|
||||||
|
|
||||||
|
@Resolver(() => TourObjectType)
|
||||||
|
export class AccountingValidationResolver {
|
||||||
|
constructor(private readonly service: AccountingValidationService) {}
|
||||||
|
|
||||||
|
@Query(() => PaginatedTourObjectType)
|
||||||
|
accountingTours(
|
||||||
|
@Args() { state, filters, cursor, take }: AccountingTourArgs,
|
||||||
|
) {
|
||||||
|
return this.service.paginateToursByState(state, filters, take, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => TourStateMetaObjectType)
|
||||||
|
accountingStateMeta(
|
||||||
|
@Args('state', { type: () => AccountingState })
|
||||||
|
accountingState: AccountingState,
|
||||||
|
) {
|
||||||
|
return this.service.getAccountingStateMeta(accountingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => TicketObjectType, { nullable: true })
|
||||||
|
ticket(@Parent() tour: TourObjectType) {
|
||||||
|
return this.service.findTicketByTicketId(tour.ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TourStateMetaObjectType } from './accounting-arg.types';
|
||||||
|
import { AccountingFilter } from './accounting-filter';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { AccountingValidationRepository } from './accounting-validation.repository';
|
||||||
|
import { PaginatedTourObjectType } from './accounting.object-type';
|
||||||
|
import {
|
||||||
|
decodeOffsetCursor,
|
||||||
|
encodeCursor,
|
||||||
|
} from 'src/core/base/pagination.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AccountingValidationService {
|
||||||
|
constructor(private readonly repository: AccountingValidationRepository) {}
|
||||||
|
|
||||||
|
async paginateToursByState(
|
||||||
|
state: AccountingState,
|
||||||
|
filter: AccountingFilter,
|
||||||
|
take: number = 10,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<PaginatedTourObjectType> {
|
||||||
|
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||||
|
const { tours, total } = await this.repository.findByState(
|
||||||
|
state,
|
||||||
|
filter,
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevPage = skip - take < 0 ? 0 : skip - take;
|
||||||
|
const nextPage = skip + take < total ? skip + take : skip;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: tours,
|
||||||
|
prevCursor: encodeCursor(prevPage),
|
||||||
|
endCursor: encodeCursor(nextPage),
|
||||||
|
hasNextPage: nextPage !== skip,
|
||||||
|
totalCount: total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountingStateMeta(
|
||||||
|
accountingState: AccountingState,
|
||||||
|
): Promise<TourStateMetaObjectType> {
|
||||||
|
return this.repository.calculateTourStateMeta(accountingState);
|
||||||
|
}
|
||||||
|
|
||||||
|
findTicketByTicketId(ticketId?: string | null) {
|
||||||
|
if (!ticketId) return;
|
||||||
|
return this.repository.findTicketByTicketId(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
import { AccountingState } from './accounting-state';
|
||||||
|
import { Paginated } from 'src/core/base/pagination.input';
|
||||||
|
import { TourObjectType } from './tour.object-type';
|
||||||
|
|
||||||
|
@ObjectType('PaginatedTour')
|
||||||
|
export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
|
||||||
|
|
||||||
|
registerEnumType(AccountingState, {
|
||||||
|
name: 'AccountingState',
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||||
|
import { AccountingValidationRepository } from './accounting-validation.repository';
|
||||||
|
import { AccountingValidationResolver } from './accounting-validation.resolver';
|
||||||
|
import { AccountingValidationService } from './accounting-validation.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [
|
||||||
|
AccountingValidationRepository,
|
||||||
|
AccountingValidationResolver,
|
||||||
|
AccountingValidationService,
|
||||||
|
],
|
||||||
|
exports: [],
|
||||||
|
})
|
||||||
|
export class ApplicationAccountingModule {}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { Tour } from '@prisma/client';
|
||||||
|
|
||||||
|
@ObjectType('Tour')
|
||||||
|
export class TourObjectType implements Tour {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
@Field()
|
||||||
|
done: boolean;
|
||||||
|
@Field()
|
||||||
|
direction: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
carName: string | null;
|
||||||
|
@Field()
|
||||||
|
operationId: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
driverId: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
driverName: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
codriverId: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
codriverName: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientId: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientName: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientSurname: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientStreet: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientZip: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
patientCity: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
healthInsurance: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
healthInsuranceNumber: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
category: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
transportType: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
ordinanceType: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startInstitution: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startStreet: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startZip: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startCity: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
targetInstitution: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
targetStreet: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
targetZip: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
targetCity: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startDate: Date;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startBegin: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
startEnd: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
target: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
targetBegin: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
empty: string | null;
|
||||||
|
@Field(() => Int)
|
||||||
|
check: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
occupiedKm: number | null;
|
||||||
|
@Field(() => Float)
|
||||||
|
totalKm: number | null;
|
||||||
|
@Field()
|
||||||
|
type: string | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
rangeEndDate: Date | null;
|
||||||
|
|
||||||
|
@Field(() => Float)
|
||||||
|
revenueDispoLive: number | null;
|
||||||
|
@Field(() => Float)
|
||||||
|
revenue: number | null;
|
||||||
|
@Field(() => Float)
|
||||||
|
consumptionCosts: number | null;
|
||||||
|
@Field(() => Float)
|
||||||
|
revenueDeviation: number;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
billDate: Date | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
billNumber: string | null;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
createdAt: Date | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Field(() => Boolean, { nullable: true })
|
||||||
|
hasInfection: boolean | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
infectionName: string | null;
|
||||||
|
|
||||||
|
@Field({ nullable: true })
|
||||||
|
ticketId: string | null;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { Anomaly, SolutionTarget, SolutionTargetStep } from '@prisma/client';
|
||||||
|
import { Paginated } from 'src/core/base/pagination.input';
|
||||||
|
|
||||||
|
@ObjectType('SolutionTarget')
|
||||||
|
export class SolutionTargetObjectType implements SolutionTarget {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
@Field()
|
||||||
|
name: string;
|
||||||
|
@Field()
|
||||||
|
anomalyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SolutionTargetStep')
|
||||||
|
export class SolutionTargetStepObjectType implements SolutionTargetStep {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
@Field()
|
||||||
|
description: string;
|
||||||
|
@Field()
|
||||||
|
solutionTargetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Anomaly')
|
||||||
|
export class AnomalyObjectType implements Anomaly {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
@Field()
|
||||||
|
type: string;
|
||||||
|
@Field()
|
||||||
|
groupKey: string;
|
||||||
|
@Field()
|
||||||
|
groupDescription: string;
|
||||||
|
@Field()
|
||||||
|
workedOnBy: string;
|
||||||
|
@Field(() => Int)
|
||||||
|
sleepTimer: number;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
sleepSince: Date | null;
|
||||||
|
@Field()
|
||||||
|
description: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
manuallyResolved: boolean | null;
|
||||||
|
@Field()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('PaginatedAnomaly')
|
||||||
|
export class PaginatedAnomalyObjectType extends Paginated(AnomalyObjectType) {}
|
||||||
|
|
||||||
|
@ObjectType('AnomalyGroup')
|
||||||
|
export class AnomalyGroupObjectType {
|
||||||
|
@Field()
|
||||||
|
key: string;
|
||||||
|
@Field(() => [AnomalyObjectType])
|
||||||
|
anomalies: AnomalyObjectType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('PaginatedAnomalyGroup')
|
||||||
|
export class PaginatedAnomalyGroupObjectType extends Paginated(
|
||||||
|
AnomalyGroupObjectType,
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ControlCenterResolver } from './control-center.resolver';
|
||||||
|
import { SolutionTargetsResolver } from './solution-targets.resolver';
|
||||||
|
import { AnomalyDetectionModule } from '../feat-anomalies/anomaly-detection.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AnomalyDetectionModule],
|
||||||
|
providers: [ControlCenterResolver, SolutionTargetsResolver],
|
||||||
|
})
|
||||||
|
export class ApplicationControlCenterModule {}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Mutation,
|
||||||
|
Parent,
|
||||||
|
Query,
|
||||||
|
ResolveField,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { PaginationArgs } from 'src/core/base/pagination.input';
|
||||||
|
import {
|
||||||
|
decodeOffsetCursor,
|
||||||
|
encodeCursor,
|
||||||
|
} from 'src/core/base/pagination.util';
|
||||||
|
import { TourObjectType } from '../app-accounting/tour.object-type';
|
||||||
|
import { AnomaliesService } from '../feat-anomalies/anomalies.service';
|
||||||
|
import { AnomalyTypes } from '../feat-anomalies/anomaly-detectors/anomaly-types.enum';
|
||||||
|
import {
|
||||||
|
AnomalyObjectType,
|
||||||
|
PaginatedAnomalyGroupObjectType,
|
||||||
|
PaginatedAnomalyObjectType,
|
||||||
|
SolutionTargetObjectType,
|
||||||
|
} from './anomaly.object-type';
|
||||||
|
|
||||||
|
@Resolver(() => AnomalyObjectType)
|
||||||
|
export class ControlCenterResolver {
|
||||||
|
constructor(private readonly anomaliesService: AnomaliesService) {}
|
||||||
|
|
||||||
|
@Query(() => AnomalyObjectType)
|
||||||
|
async anomaly(@Args('id') id: string) {
|
||||||
|
return this.anomaliesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [AnomalyObjectType])
|
||||||
|
async anomaliesProto() {
|
||||||
|
return this.anomaliesService.findActiveAnomalies();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => PaginatedAnomalyObjectType)
|
||||||
|
async anomalies(@Args() { cursor, take }: PaginationArgs) {
|
||||||
|
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||||
|
const activeAnomalies = await this.anomaliesService.findActiveAnomalies(
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
[AnomalyTypes.WorkingEmployeeNotInDistributionSystem],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = await this.anomaliesService.countActiveAnomalies([
|
||||||
|
AnomalyTypes.WorkingEmployeeNotInDistributionSystem,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prevPage = skip - take < 0 ? 0 : skip - take;
|
||||||
|
const nextPage = skip + take < totalCount ? skip + take : skip;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: activeAnomalies,
|
||||||
|
prevCursor: encodeCursor(prevPage),
|
||||||
|
endCursor: encodeCursor(nextPage),
|
||||||
|
hasNextPage: nextPage !== skip,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => PaginatedAnomalyGroupObjectType)
|
||||||
|
async groupedAnomalies(@Args() { cursor, take }: PaginationArgs) {
|
||||||
|
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||||
|
|
||||||
|
const { totalCount, anomalies: groupedActiveAnomalies } =
|
||||||
|
await this.anomaliesService.findActiveAnomaliesGroupedByGroupKey(
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevPage = skip - take < 0 ? 0 : skip - take;
|
||||||
|
const nextPage = skip + take < totalCount ? skip + take : skip;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: groupedActiveAnomalies,
|
||||||
|
prevCursor: encodeCursor(prevPage),
|
||||||
|
endCursor: encodeCursor(nextPage),
|
||||||
|
hasNextPage: nextPage !== skip,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => AnomalyObjectType)
|
||||||
|
async setAnomalyToSleep(@Args({ name: 'id' }) anomalyId: string) {
|
||||||
|
return this.anomaliesService.setAnomalyToSleep(anomalyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => AnomalyObjectType)
|
||||||
|
async acceptAnomaly(@Args({ name: 'id' }) anomalyId: string) {
|
||||||
|
return this.anomaliesService.acceptAnomaly(anomalyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => TourObjectType, { nullable: true })
|
||||||
|
tour(@Parent() anomaly: AnomalyObjectType) {
|
||||||
|
const [operationId] = anomaly.groupKey.split('-').reverse();
|
||||||
|
return this.anomaliesService.findTourByOperationId(operationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => [SolutionTargetObjectType])
|
||||||
|
solution(
|
||||||
|
@Parent() anomaly: AnomalyObjectType,
|
||||||
|
): Promise<SolutionTargetObjectType[]> {
|
||||||
|
return this.anomaliesService.findSolutionTargetsByAnomalyId(anomaly.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { AnomaliesService } from '../feat-anomalies/anomalies.service';
|
||||||
|
import {
|
||||||
|
SolutionTargetObjectType,
|
||||||
|
SolutionTargetStepObjectType,
|
||||||
|
} from './anomaly.object-type';
|
||||||
|
|
||||||
|
@Resolver(() => SolutionTargetObjectType)
|
||||||
|
export class SolutionTargetsResolver {
|
||||||
|
constructor(private readonly anomaliesService: AnomaliesService) {}
|
||||||
|
|
||||||
|
@ResolveField(() => [SolutionTargetStepObjectType!]!)
|
||||||
|
steps(
|
||||||
|
@Parent() solutionTarget: SolutionTargetObjectType,
|
||||||
|
): Promise<SolutionTargetStepObjectType[]> {
|
||||||
|
return this.anomaliesService.findSolutionTargetStepsBySolutionTargetId(
|
||||||
|
solutionTarget.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||||
|
import { FeatureCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||||
|
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||||
|
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
||||||
|
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
||||||
|
import { ManagerResolver } from './manager.resolver';
|
||||||
|
import { CarEntityService } from './car-entity/car-entity.service';
|
||||||
|
import { CarEntityRepository } from './car-entity/car-entity.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, FeatureCalculationModule],
|
||||||
|
providers: [
|
||||||
|
TourAmountForecastService,
|
||||||
|
TimePerTourKpiService,
|
||||||
|
TimePerTourMetricsService,
|
||||||
|
CarEntityService,
|
||||||
|
CarEntityRepository,
|
||||||
|
ManagerResolver,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ApplicationManagerModule {}
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PlannedTime, Tour } from '@prisma/client';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
import { PrismaService } from 'src/core/database/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CarEntityRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findToursInInterval(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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
patientId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findPlannedTimeByOrdinanceTypesInInterval(
|
||||||
|
ordinanceType: string,
|
||||||
|
interval: Interval,
|
||||||
|
): Promise<PlannedTime[]> {
|
||||||
|
return this.prisma.plannedTime.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
gte: new Date(interval.start),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
lte: new Date(interval.end),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
department: {
|
||||||
|
contains: ordinanceType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
department: {
|
||||||
|
not: 'Leitstelle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
department: {
|
||||||
|
not: 'Büro',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: {
|
||||||
|
not: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PlannedTime, Tour } from '@prisma/client';
|
||||||
|
import { CarEntityRepository } from './car-entity.repository';
|
||||||
|
import { CarEntity } from './car.entity';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
import { generateIntervalForTimeRange } from 'src/utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CarEntityService {
|
||||||
|
constructor(private readonly repository: CarEntityRepository) {}
|
||||||
|
|
||||||
|
async getCarsInInterval(
|
||||||
|
interval: Interval,
|
||||||
|
ordinanceType: string,
|
||||||
|
): Promise<CarEntity[]> {
|
||||||
|
const carEntities = await this.generateCarEntitiesForInterval(
|
||||||
|
interval,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
return carEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCarsForOperationDay(
|
||||||
|
day: Date,
|
||||||
|
ordinanceType: string,
|
||||||
|
): Promise<CarEntity[]> {
|
||||||
|
const operationDayInterval = generateIntervalForTimeRange(
|
||||||
|
'05:00-04:59',
|
||||||
|
'Europe/Berlin',
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
|
||||||
|
const carEntities = await this.generateCarEntitiesForInterval(
|
||||||
|
operationDayInterval,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
return carEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateCarEntitiesForInterval(
|
||||||
|
interval: Interval,
|
||||||
|
ordinanceType: string,
|
||||||
|
): Promise<CarEntity[]> {
|
||||||
|
const toursInFullRange =
|
||||||
|
await this.repository.findToursInInterval(interval);
|
||||||
|
const plannedTimeByOrdinanceType =
|
||||||
|
await this.repository.findPlannedTimeByOrdinanceTypesInInterval(
|
||||||
|
ordinanceType,
|
||||||
|
interval,
|
||||||
|
);
|
||||||
|
return this.generateCarEntities(
|
||||||
|
toursInFullRange,
|
||||||
|
plannedTimeByOrdinanceType,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateCarEntities(
|
||||||
|
tours: Tour[],
|
||||||
|
plannedTimes: PlannedTime[],
|
||||||
|
ordinanceType: string,
|
||||||
|
): CarEntity[] {
|
||||||
|
const departmentsAsCarNames = [
|
||||||
|
...new Set(plannedTimes.map(({ department }) => department)),
|
||||||
|
];
|
||||||
|
|
||||||
|
const toursWithoutCarName = tours
|
||||||
|
.filter((tour) => tour?.ordinanceType?.includes(ordinanceType))
|
||||||
|
.filter(({ carName }) => !carName);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...departmentsAsCarNames.map((departmentAsCarName) => {
|
||||||
|
const toursForCarName = tours.filter(({ carName }) =>
|
||||||
|
this.isCarNameForDepartment(departmentAsCarName, carName),
|
||||||
|
);
|
||||||
|
const plannedTimesForCarName = plannedTimes.filter(
|
||||||
|
({ department }) => department === departmentAsCarName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new CarEntity(
|
||||||
|
toursForCarName,
|
||||||
|
plannedTimesForCarName,
|
||||||
|
departmentAsCarName,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
...(toursWithoutCarName.length
|
||||||
|
? [new CarEntity(toursWithoutCarName, [], undefined, ordinanceType)]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCarNameForDepartment(department: string, carName: string): boolean {
|
||||||
|
if (!department || !carName) return false;
|
||||||
|
const [ordinanceType, carNumber] = department
|
||||||
|
.replace('1/', '')
|
||||||
|
.replace('E', '')
|
||||||
|
.split('-')
|
||||||
|
.map((part) => part.trim());
|
||||||
|
return (
|
||||||
|
(carName.indexOf(ordinanceType) > -1 &&
|
||||||
|
carName.indexOf(carNumber) > -1) ||
|
||||||
|
carName === department
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { PlannedTime, Tour } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
areIntervalsOverlapping,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
Interval,
|
||||||
|
isWithinInterval,
|
||||||
|
startOfDay,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { CalculationService } from 'src/modules/feat-calculation/calculation.service';
|
||||||
|
import { clampInterval, getHoursInInterval } from 'src/utils';
|
||||||
|
|
||||||
|
const EMPLOYEE_AMOUNT_BY_ORDINANCE_TYPE = {
|
||||||
|
BTW: 1,
|
||||||
|
TSW: 2,
|
||||||
|
KTW: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CarEntity {
|
||||||
|
constructor(
|
||||||
|
private readonly tours: Tour[],
|
||||||
|
private readonly plannedTimes: PlannedTime[],
|
||||||
|
public readonly carName: string,
|
||||||
|
private readonly ordinanceType: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
availableTimeInInterval(interval: Interval): number {
|
||||||
|
return (
|
||||||
|
this.plannedTimes
|
||||||
|
.map(({ startDate, endDate }) => ({
|
||||||
|
start: new Date(startDate),
|
||||||
|
end: new Date(endDate),
|
||||||
|
}))
|
||||||
|
.filter((plannedTimeInterval) =>
|
||||||
|
areIntervalsOverlapping(plannedTimeInterval, interval),
|
||||||
|
)
|
||||||
|
.map((plannedTimeInterval) =>
|
||||||
|
getHoursInInterval(plannedTimeInterval, interval),
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.at(0) ?? 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tourAmountInInterval(interval: Interval): number {
|
||||||
|
return this.toursInInterval(interval).reduce(
|
||||||
|
(sum, { occupiedKm }) => (sum += occupiedKm > 30 ? occupiedKm / 30 : 1),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toursInInterval(interval: Interval): Tour[] {
|
||||||
|
return this.tours
|
||||||
|
.filter(({ startDate }) =>
|
||||||
|
isWithinInterval(new Date(startDate), interval),
|
||||||
|
)
|
||||||
|
.filter(({ occupiedKm }) => occupiedKm < 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
async breakEvenInInterval(
|
||||||
|
interval: Interval,
|
||||||
|
calculationService: CalculationService,
|
||||||
|
): Promise<{
|
||||||
|
toursRevenue;
|
||||||
|
personnelCosts;
|
||||||
|
consumptionCost;
|
||||||
|
}> {
|
||||||
|
const toursInInterval = this.toursInInterval(interval);
|
||||||
|
|
||||||
|
const toursRevenue = toursInInterval.reduce(
|
||||||
|
(sum, tour) => (sum += tour.revenue),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const consumptionCost = toursInInterval.reduce(
|
||||||
|
(sum, tour) => (sum += tour.consumptionCosts),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const plannedTimeIntervalInInterval =
|
||||||
|
this.plannedTimeIntervalInInterval(interval);
|
||||||
|
|
||||||
|
if (!plannedTimeIntervalInInterval) {
|
||||||
|
return {
|
||||||
|
toursRevenue,
|
||||||
|
personnelCosts: 0,
|
||||||
|
consumptionCost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const personnelCosts =
|
||||||
|
calculationService.calculatePersonnelCostInInterval(
|
||||||
|
clampInterval(plannedTimeIntervalInInterval, interval),
|
||||||
|
this.employeeAmount,
|
||||||
|
this.ordinanceType,
|
||||||
|
) / 100;
|
||||||
|
return {
|
||||||
|
toursRevenue,
|
||||||
|
personnelCosts,
|
||||||
|
consumptionCost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getTourAmountAtDateCreatedToday(date: Date): number {
|
||||||
|
const dateInterval: Interval = {
|
||||||
|
start: startOfDay(date),
|
||||||
|
end: endOfDay(date),
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayInterval: Interval = {
|
||||||
|
start: startOfDay(new Date()),
|
||||||
|
end: endOfDay(new Date()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.tours.filter(
|
||||||
|
({ startDate, createdAt }) =>
|
||||||
|
isWithinInterval(new Date(startDate), dateInterval) &&
|
||||||
|
isWithinInterval(new Date(createdAt), todayInterval),
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this.carName}
|
||||||
|
${this.tours.length} Touren
|
||||||
|
${this.plannedTimes.length} geplante Mitarbeiter
|
||||||
|
${
|
||||||
|
this.plannedTimes.length
|
||||||
|
? this.plannedTimes
|
||||||
|
.map(
|
||||||
|
({ startDate, endDate }) =>
|
||||||
|
`${format(new Date(startDate), 'HH:mm')}-${format(
|
||||||
|
new Date(endDate),
|
||||||
|
'HH:mm',
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
.join(', ')
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private plannedTimeIntervalInInterval(interval: Interval): Interval {
|
||||||
|
return this.plannedTimes
|
||||||
|
.map(({ startDate, endDate }) => ({
|
||||||
|
start: new Date(startDate),
|
||||||
|
end: new Date(endDate),
|
||||||
|
}))
|
||||||
|
.filter((plannedTimeInterval) =>
|
||||||
|
areIntervalsOverlapping(plannedTimeInterval, interval),
|
||||||
|
)
|
||||||
|
.at(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasPlannedTimeInInterval(interval: Interval): boolean {
|
||||||
|
return !!this.plannedTimeIntervalInInterval(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get employeeAmount(): number {
|
||||||
|
return EMPLOYEE_AMOUNT_BY_ORDINANCE_TYPE[this.ordinanceType];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
import { IntervalObjectType } from 'src/core/base/interval.object-type';
|
||||||
|
|
||||||
|
@ObjectType('KPI')
|
||||||
|
export class KPIObjectType {
|
||||||
|
@Field(() => Float)
|
||||||
|
value: number;
|
||||||
|
@Field(() => KPIMetadataObjectType)
|
||||||
|
meta: {
|
||||||
|
tourAmount: number;
|
||||||
|
availableTime: number;
|
||||||
|
baseValue: number;
|
||||||
|
breakEven: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('KPIByCar')
|
||||||
|
export class KPIByCarObjectType {
|
||||||
|
@Field()
|
||||||
|
carName: string;
|
||||||
|
@Field(() => KPIObjectType)
|
||||||
|
kpi: KPIObjectType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TimePerTourKPISummary')
|
||||||
|
export class TimePerTourKPISummaryObjectType {
|
||||||
|
@Field(() => IntervalObjectType)
|
||||||
|
interval: Interval;
|
||||||
|
@Field(() => KPIObjectType)
|
||||||
|
kpi: KPIObjectType;
|
||||||
|
@Field(() => [KPIByCarObjectType])
|
||||||
|
cars: KPIByCarObjectType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('KPIMetadata')
|
||||||
|
export class KPIMetadataObjectType {
|
||||||
|
@Field(() => Float)
|
||||||
|
tourAmount: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
availableTime: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
baseValue: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
breakEven: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TimePerTourUpdate')
|
||||||
|
export class TimePerTourUpdateObjectType {
|
||||||
|
@Field()
|
||||||
|
job: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TimePerTourMetric')
|
||||||
|
export class TimePerTourMetricObjectType {
|
||||||
|
@Field(() => IntervalObjectType)
|
||||||
|
interval: Interval;
|
||||||
|
@Field(() => Float)
|
||||||
|
availableTime: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
tourAmount: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
breakEven: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
revenue: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
averageRevenue: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
personnelCost: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
consumptionCost: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
forecast: number;
|
||||||
|
@Field(() => Float)
|
||||||
|
forecastRevenue: number;
|
||||||
|
@Field(() => [TimePerTourMetricCategoryAmountObjectType])
|
||||||
|
categoryAmounts: TimePerTourMetricCategoryAmountObjectType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TimePerTourMetricCategoryAmount')
|
||||||
|
export class TimePerTourMetricCategoryAmountObjectType {
|
||||||
|
@Field()
|
||||||
|
category: string;
|
||||||
|
@Field(() => Int)
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Args, Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
TimePerTourKPISummaryObjectType,
|
||||||
|
TimePerTourMetricObjectType,
|
||||||
|
TimePerTourUpdateObjectType,
|
||||||
|
} from './manager.object-type';
|
||||||
|
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
|
||||||
|
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class ManagerResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly timePerTourKpiService: TimePerTourKpiService,
|
||||||
|
private readonly timePerTourMetricService: TimePerTourMetricsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Query(() => [TimePerTourKPISummaryObjectType])
|
||||||
|
toursPerTimeKPI(
|
||||||
|
@Args('ordinanceTypes', { type: () => String }) ordinanceType: string,
|
||||||
|
) {
|
||||||
|
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [TimePerTourMetricObjectType])
|
||||||
|
timePerTourMetricForDay(
|
||||||
|
@Args('day', { type: () => Date }) day: Date,
|
||||||
|
@Args('ordinanceType', { type: () => String }) ordinanceType: string,
|
||||||
|
) {
|
||||||
|
return this.timePerTourMetricService.forDay(day, ordinanceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [TimePerTourMetricObjectType])
|
||||||
|
timePerTourMetricForMonth(
|
||||||
|
@Args('day', { type: () => Date }) day: Date,
|
||||||
|
@Args('ordinanceType', { type: () => String }) ordinanceType: string,
|
||||||
|
) {
|
||||||
|
return this.timePerTourMetricService.forMonth(day, ordinanceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscription(() => TimePerTourUpdateObjectType)
|
||||||
|
timePerTourUpdated() {
|
||||||
|
return this.timePerTourKpiService.pubSub.asyncIterator(
|
||||||
|
'timePerTourUpdated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { addMilliseconds } from 'date-fns';
|
||||||
|
import { PubSub } from 'graphql-subscriptions';
|
||||||
|
import { generateIntervalForTimeRange } from 'src/utils';
|
||||||
|
import { CarEntityService } from './car-entity/car-entity.service';
|
||||||
|
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||||
|
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
|
||||||
|
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimePerTourKpiService {
|
||||||
|
public readonly pubSub = new PubSub();
|
||||||
|
|
||||||
|
@OnEvent(ETLEventTypes.LoadingDone, { async: true })
|
||||||
|
notifyOnPlannedTimeUpdate(event: LoadingDoneEvent): void {
|
||||||
|
this.pubSub.publish('timePerTourUpdated', {
|
||||||
|
timePerTourUpdated: { job: event.jobId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly calculationService: CalculationService,
|
||||||
|
private readonly carEntityService: CarEntityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async toursPerTimeKPI(ordinanceType: string, date = new Date()) {
|
||||||
|
const fullRangeInterval = generateIntervalForTimeRange(
|
||||||
|
'05:00-01:00',
|
||||||
|
'Europe/Berlin',
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subIntervals = [
|
||||||
|
generateIntervalForTimeRange('05:00-12:00', 'Europe/Berlin'),
|
||||||
|
generateIntervalForTimeRange('12:00-18:30', 'Europe/Berlin'),
|
||||||
|
generateIntervalForTimeRange('18:30-01:00', 'Europe/Berlin'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const carEntities = await this.carEntityService.getCarsInInterval(
|
||||||
|
fullRangeInterval,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
subIntervals
|
||||||
|
.map(({ start, end }, index, array) => ({
|
||||||
|
start,
|
||||||
|
end: index + 1 !== array.length ? addMilliseconds(end, -1) : end,
|
||||||
|
}))
|
||||||
|
.map(async (interval) => {
|
||||||
|
const cars = await Promise.all(
|
||||||
|
carEntities.map(async (carEntity) => {
|
||||||
|
const tourAmount = carEntity.tourAmountInInterval(interval);
|
||||||
|
const availableTime = carEntity.availableTimeInInterval(interval);
|
||||||
|
|
||||||
|
const { personnelCosts, consumptionCost, toursRevenue } =
|
||||||
|
await carEntity.breakEvenInInterval(
|
||||||
|
interval,
|
||||||
|
this.calculationService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseValue = this.calculateKPIBaseValue(
|
||||||
|
availableTime,
|
||||||
|
tourAmount,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
carName: carEntity.carName ?? 'Touren ohne Zuweisung',
|
||||||
|
kpi: {
|
||||||
|
value: !carEntity.carName ? tourAmount : baseValue,
|
||||||
|
meta: {
|
||||||
|
tourAmount: Math.round(tourAmount * 100) / 100,
|
||||||
|
availableTime: Math.round(availableTime * 100) / 100,
|
||||||
|
baseValue,
|
||||||
|
breakEven:
|
||||||
|
Math.round(
|
||||||
|
(toursRevenue - personnelCosts - consumptionCost) * 100,
|
||||||
|
) / 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tourAmount = carEntities.reduce(
|
||||||
|
(sum, carEntity) =>
|
||||||
|
(sum += carEntity.tourAmountInInterval(interval)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableTime = carEntities.reduce(
|
||||||
|
(sum, carEntity) =>
|
||||||
|
(sum += carEntity.availableTimeInInterval(interval)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let revenue = 0;
|
||||||
|
let personnelCost = 0;
|
||||||
|
let consumptionCost = 0;
|
||||||
|
for (const carEntity of carEntities) {
|
||||||
|
const {
|
||||||
|
personnelCosts,
|
||||||
|
consumptionCost: cc,
|
||||||
|
toursRevenue,
|
||||||
|
} = await carEntity.breakEvenInInterval(
|
||||||
|
interval,
|
||||||
|
this.calculationService,
|
||||||
|
);
|
||||||
|
revenue += toursRevenue;
|
||||||
|
personnelCost += personnelCosts;
|
||||||
|
consumptionCost += cc;
|
||||||
|
}
|
||||||
|
const baseValue = this.calculateKPIBaseValue(
|
||||||
|
availableTime,
|
||||||
|
tourAmount,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
interval,
|
||||||
|
kpi: {
|
||||||
|
value: baseValue,
|
||||||
|
meta: {
|
||||||
|
tourAmount: Math.round(tourAmount * 100) / 100,
|
||||||
|
availableTime: Math.round(availableTime * 100) / 100,
|
||||||
|
baseValue,
|
||||||
|
breakEven:
|
||||||
|
Math.round(
|
||||||
|
(revenue - personnelCost - consumptionCost) * 100,
|
||||||
|
) / 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cars,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateKPIBaseValue(availableTime: number, tourAmount: number): number {
|
||||||
|
if (tourAmount === 0 && availableTime > 0) {
|
||||||
|
return Math.round((availableTime / 0.01) * 1000) / 1000;
|
||||||
|
} else if (tourAmount === 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return Math.round((availableTime / tourAmount) * 1000) / 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
addMilliseconds,
|
||||||
|
areIntervalsOverlapping,
|
||||||
|
differenceInCalendarDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
eachHourOfInterval,
|
||||||
|
endOfDay,
|
||||||
|
endOfMonth,
|
||||||
|
getISODay,
|
||||||
|
Interval,
|
||||||
|
isSunday,
|
||||||
|
set,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { CarEntityService } from './car-entity/car-entity.service';
|
||||||
|
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||||
|
import { TimePerTourMetricObjectType } from './manager.object-type';
|
||||||
|
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||||
|
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimePerTourMetricsService {
|
||||||
|
constructor(
|
||||||
|
private readonly calculationService: CalculationService,
|
||||||
|
private readonly tourAmountForecastService: TourAmountForecastService,
|
||||||
|
private readonly carEntityService: CarEntityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async forMonth(date: Date, ordinanceType: string) {
|
||||||
|
const fullRangeInterval = {
|
||||||
|
start: startOfMonth(date),
|
||||||
|
end: endOfMonth(date),
|
||||||
|
};
|
||||||
|
|
||||||
|
const subIntervals = eachDayOfInterval(fullRangeInterval)
|
||||||
|
.filter((day) => !isSunday(day))
|
||||||
|
.reduce(
|
||||||
|
(subIntervals, day) => [
|
||||||
|
...subIntervals,
|
||||||
|
generateIntervalForTimeRange('05:00-04:59', 'Europe/Berlin', day),
|
||||||
|
],
|
||||||
|
[] as Interval[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.generateTimePerTourMetric(
|
||||||
|
fullRangeInterval,
|
||||||
|
subIntervals,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async forDay(date: Date, ordinanceType: string) {
|
||||||
|
const fullRangeInterval = generateIntervalForTimeRange(
|
||||||
|
'05:00-04:59',
|
||||||
|
'Europe/Berlin',
|
||||||
|
date,
|
||||||
|
);
|
||||||
|
const subIntervals = eachHourOfInterval(fullRangeInterval)
|
||||||
|
.reduce(
|
||||||
|
(subIntervals, hour, index, array) => [
|
||||||
|
...subIntervals,
|
||||||
|
...(index === array.length - 1
|
||||||
|
? []
|
||||||
|
: [{ start: hour, end: array[index + 1] }]),
|
||||||
|
],
|
||||||
|
[] as Interval[],
|
||||||
|
)
|
||||||
|
.map(({ start, end }, index, array) => ({
|
||||||
|
start,
|
||||||
|
end: index + 1 !== array.length ? addMilliseconds(end, -1) : end,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.generateTimePerTourMetric(
|
||||||
|
fullRangeInterval,
|
||||||
|
subIntervals,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateTimePerTourMetric(
|
||||||
|
intervalClamp: Interval,
|
||||||
|
subIntervals: Interval[],
|
||||||
|
ordinanceType: string,
|
||||||
|
): Promise<TimePerTourMetricObjectType[]> {
|
||||||
|
const carEntities = await this.carEntityService.getCarsInInterval(
|
||||||
|
intervalClamp,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
const forecastPeriod: Interval = {
|
||||||
|
start: startOfDay(new Date()),
|
||||||
|
end: endOfDay(addDays(new Date(), 7)),
|
||||||
|
};
|
||||||
|
const forecastForOrdinanceType =
|
||||||
|
await this.calculateForecastForOrdinanceType(
|
||||||
|
forecastPeriod,
|
||||||
|
intervalClamp,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
subIntervals.map(async (interval) => {
|
||||||
|
const tourAmount = carEntities.reduce(
|
||||||
|
(sum, carEntity) => (sum += carEntity.tourAmountInInterval(interval)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const availableTime = carEntities.reduce(
|
||||||
|
(sum, carEntity) =>
|
||||||
|
(sum += carEntity.availableTimeInInterval(interval)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let revenue = 0;
|
||||||
|
let personnelCost = 0;
|
||||||
|
let consumptionCost = 0;
|
||||||
|
|
||||||
|
const categoryAmounts = Object.entries(
|
||||||
|
groupBy(
|
||||||
|
carEntities.flatMap((ce) => ce.toursInInterval(interval)),
|
||||||
|
({ category }) => category,
|
||||||
|
),
|
||||||
|
).reduce(
|
||||||
|
(sum, [category, tours]) => [
|
||||||
|
...sum,
|
||||||
|
{ category, amount: tours.length },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
for (const carEntity of carEntities) {
|
||||||
|
const {
|
||||||
|
personnelCosts,
|
||||||
|
consumptionCost: cc,
|
||||||
|
toursRevenue,
|
||||||
|
} = await carEntity.breakEvenInInterval(
|
||||||
|
interval,
|
||||||
|
this.calculationService,
|
||||||
|
);
|
||||||
|
revenue += toursRevenue;
|
||||||
|
personnelCost += personnelCosts;
|
||||||
|
consumptionCost += cc;
|
||||||
|
}
|
||||||
|
let forecast = 0;
|
||||||
|
let forecastRevenue = 0;
|
||||||
|
if (areIntervalsOverlapping(interval, forecastPeriod)) {
|
||||||
|
const dayDiffFromNow = differenceInCalendarDays(
|
||||||
|
interval.start,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
const weekday = getISODay(interval.start);
|
||||||
|
const tourAmountAtDateCreatedToday = carEntities.reduce(
|
||||||
|
(sum, carEntity) =>
|
||||||
|
(sum += carEntity.getTourAmountAtDateCreatedToday(
|
||||||
|
new Date(interval.start),
|
||||||
|
)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
forecast =
|
||||||
|
forecastForOrdinanceType[weekday]
|
||||||
|
?.slice(0, dayDiffFromNow + 1)
|
||||||
|
.reduce((sum, tourAmount) => (sum += tourAmount), 0) -
|
||||||
|
(tourAmountAtDateCreatedToday ?? 0);
|
||||||
|
|
||||||
|
if (forecast >= 0) {
|
||||||
|
const billingInformation =
|
||||||
|
this.calculationService.getModellBillingInformationByOrdinanceType(
|
||||||
|
ordinanceType,
|
||||||
|
set(new Date(), {
|
||||||
|
hours: 13,
|
||||||
|
minutes: 0,
|
||||||
|
seconds: 0,
|
||||||
|
milliseconds: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
forecastRevenue =
|
||||||
|
billingInformation.revenuePerTour * Math.round(forecast) -
|
||||||
|
billingInformation.consumptionCostsPerTour * Math.round(forecast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interval,
|
||||||
|
tourAmount: Math.round(tourAmount * 100) / 100,
|
||||||
|
availableTime: Math.round(availableTime * 100) / 100,
|
||||||
|
breakEven:
|
||||||
|
Math.round((revenue - personnelCost - consumptionCost) * 100) / 100,
|
||||||
|
revenue,
|
||||||
|
averageRevenue: isNaN(revenue / tourAmount)
|
||||||
|
? 0
|
||||||
|
: revenue / tourAmount,
|
||||||
|
personnelCost,
|
||||||
|
consumptionCost,
|
||||||
|
forecast: forecast < 0 || isNaN(forecast) ? 0 : forecast,
|
||||||
|
forecastRevenue,
|
||||||
|
categoryAmounts,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async calculateForecastForOrdinanceType(
|
||||||
|
forecastPeriod: Interval,
|
||||||
|
requestedInterval: Interval,
|
||||||
|
ordinanceType: string,
|
||||||
|
): Promise<Record<string, number[]>> {
|
||||||
|
if (areIntervalsOverlapping(requestedInterval, forecastPeriod)) {
|
||||||
|
return this.tourAmountForecastService.calculateTourAmountForecastFourWeekPeriod(
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Tour } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
differenceInCalendarDays,
|
||||||
|
eachDayOfInterval,
|
||||||
|
endOfDay,
|
||||||
|
format,
|
||||||
|
getISODay,
|
||||||
|
Interval,
|
||||||
|
isSameDay,
|
||||||
|
startOfDay,
|
||||||
|
} from 'date-fns';
|
||||||
|
import { PrismaService } from 'src/core/database/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TourAmountForecastService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
calculateTourAmountForecastFourWeekPeriod(
|
||||||
|
ordinanceType: string | undefined,
|
||||||
|
): Promise<Record<string, number[]>> {
|
||||||
|
return this.calculateTourAmountForecastForInterval(
|
||||||
|
{
|
||||||
|
start: startOfDay(addDays(new Date(), -28)),
|
||||||
|
end: endOfDay(new Date()),
|
||||||
|
},
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTourAmountForecastForInterval(
|
||||||
|
interval: Interval,
|
||||||
|
ordinanceType: string | undefined,
|
||||||
|
): Promise<Record<string, number[]>> {
|
||||||
|
const toursInInterval = await this.findToursInInterval(
|
||||||
|
interval,
|
||||||
|
ordinanceType,
|
||||||
|
);
|
||||||
|
const toursGroupedByDay = this.groupToursByDay(interval, toursInInterval);
|
||||||
|
|
||||||
|
const tourCreationToStartDiffByDay = Object.entries(
|
||||||
|
toursGroupedByDay,
|
||||||
|
).reduce(
|
||||||
|
(group, [dateString, tours]) => ({
|
||||||
|
...group,
|
||||||
|
[dateString]: this.calculateCreationToStartDifferenceOfTours(tours),
|
||||||
|
}),
|
||||||
|
{} as Record<string, Record<string, number>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tourCreationToStartDiffsByWeekday = Object.entries(
|
||||||
|
tourCreationToStartDiffByDay,
|
||||||
|
).reduce(
|
||||||
|
(group, [dateString, diffs]) => {
|
||||||
|
const weekday = getISODay(new Date(dateString));
|
||||||
|
const existingDiffs = group[weekday] ?? {};
|
||||||
|
const dayDifferences = [0, 1, 2, 3, 4, 5, 6, 7];
|
||||||
|
const diffsCombined = dayDifferences.reduce(
|
||||||
|
(combined, dayDifference) => {
|
||||||
|
const diffForDay = diffs[dayDifference];
|
||||||
|
return {
|
||||||
|
...combined,
|
||||||
|
[dayDifference]: [
|
||||||
|
...(existingDiffs[dayDifference] ?? []),
|
||||||
|
diffForDay,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{} as Record<string, number[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
[weekday]: diffsCombined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, number[]>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tourAmountForecastByWeekday = Object.entries(
|
||||||
|
tourCreationToStartDiffsByWeekday,
|
||||||
|
).reduce(
|
||||||
|
(group, [weekday, diffs]) => {
|
||||||
|
const diffsMedian = Object.entries(diffs).reduce(
|
||||||
|
(combined, [, tourAmounts]) => {
|
||||||
|
const median =
|
||||||
|
tourAmounts.reduce(
|
||||||
|
(sum, tourAmount) => (sum += tourAmount),
|
||||||
|
0.0,
|
||||||
|
) / tourAmounts.length;
|
||||||
|
return [...combined, median];
|
||||||
|
},
|
||||||
|
[] as number[],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
[weekday]: diffsMedian,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{} as Record<string, number[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return tourAmountForecastByWeekday;
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupToursByDay(
|
||||||
|
interval: Interval,
|
||||||
|
tours: Tour[],
|
||||||
|
): Record<string, Tour[]> {
|
||||||
|
const daysInInterval = eachDayOfInterval(interval);
|
||||||
|
return daysInInterval.reduce(
|
||||||
|
(group, day) => {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
[format(day, 'yyyy-MM-dd')]: tours.filter(({ startDate }) =>
|
||||||
|
isSameDay(day, new Date(startDate)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{} as Record<string, Tour[]>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateCreationToStartDifferenceOfTours(
|
||||||
|
tours: Tour[],
|
||||||
|
): Record<string, number> {
|
||||||
|
return tours.reduce(
|
||||||
|
(diffs, tour) => {
|
||||||
|
const tourCreationToStartDiff = differenceInCalendarDays(
|
||||||
|
new Date(tour.startDate),
|
||||||
|
new Date(tour.createdAt),
|
||||||
|
);
|
||||||
|
if (tourCreationToStartDiff > 7 || tourCreationToStartDiff < 0) {
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...diffs,
|
||||||
|
[tourCreationToStartDiff]: (diffs[tourCreationToStartDiff] ?? 0) + 1,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 } as Record<
|
||||||
|
string,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findToursInInterval(
|
||||||
|
interval: Interval,
|
||||||
|
ordinanceType: string | undefined,
|
||||||
|
) {
|
||||||
|
const ordinanceTypeToQuery = ordinanceType.split(' ')[0];
|
||||||
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
gte: new Date(interval.start),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
lt: new Date(interval.end),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(ordinanceType
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
ordinanceType: ordinanceTypeToQuery,
|
||||||
|
},
|
||||||
|
...(ordinanceType.indexOf('TSW') > -1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
transportType:
|
||||||
|
ordinanceType.indexOf('1') > -1 ? 'TS1' : 'TS2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
carName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Storno',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Löschen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Abgabe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: {
|
||||||
|
not: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
patientId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
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 { TicketsResolver } from './tickets.resolver';
|
||||||
|
import { TicketService } from './tickets.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [
|
||||||
|
TicketRepository,
|
||||||
|
TicketService,
|
||||||
|
TicketLifecycleService,
|
||||||
|
TicketsResolver,
|
||||||
|
],
|
||||||
|
exports: [TicketService],
|
||||||
|
})
|
||||||
|
export class ApplicationTicketSystemModule {}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { ArgsType, Field, InputType } from '@nestjs/graphql';
|
||||||
|
import { ApprovalState, TicketValidationState } from '@prisma/client';
|
||||||
|
import { PaginationArgs } from 'src/core/base/pagination.input';
|
||||||
|
import { TicketFilter } from './ticket-filter';
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class TicketFilterArgs implements TicketFilter {
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
errors: string[];
|
||||||
|
@Field(() => [ApprovalState], { nullable: true })
|
||||||
|
approvalStates: ApprovalState[];
|
||||||
|
@Field(() => [String], { nullable: true })
|
||||||
|
documentInfo: ('approval' | 'transport_document')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class TicketsArgs extends PaginationArgs {
|
||||||
|
@Field(() => TicketValidationState)
|
||||||
|
state: TicketValidationState;
|
||||||
|
@Field(() => TicketFilterArgs, { nullable: true })
|
||||||
|
filters: TicketFilterArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class ApprovalInfoArgs {
|
||||||
|
@Field({ nullable: true })
|
||||||
|
isNeeded?: boolean;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
whoRequested?: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
answeredFrom?: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class DocumentInfoArgs {
|
||||||
|
@Field(() => ApprovalInfoArgs, { nullable: true })
|
||||||
|
approval: ApprovalInfoArgs;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
tdLocation?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { ApprovalState, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface TicketFilter {
|
||||||
|
errors?: string[];
|
||||||
|
approvalStates?: ApprovalState[];
|
||||||
|
documentInfo?: ('approval' | 'transport_document')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrismaQueryFromTicketFilters(
|
||||||
|
filter: TicketFilter,
|
||||||
|
): Prisma.TicketWhereInput[] {
|
||||||
|
const conditions: Prisma.TicketWhereInput[] = [];
|
||||||
|
|
||||||
|
Object.entries(filter ?? {}).forEach(([property, args]) => {
|
||||||
|
switch (property) {
|
||||||
|
case 'errors':
|
||||||
|
if (args.length > 0)
|
||||||
|
conditions.push({
|
||||||
|
errors: {
|
||||||
|
hasSome: [...args],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'approvalStates':
|
||||||
|
if (args.length > 0)
|
||||||
|
conditions.push({
|
||||||
|
approvalState: {
|
||||||
|
in: [...args],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case 'documentInfo':
|
||||||
|
if (args.includes('approval')) {
|
||||||
|
conditions.push({
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
documentInfo: {
|
||||||
|
path: ['approval', 'location'],
|
||||||
|
not: 'company',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
documentInfo: {
|
||||||
|
path: ['approval', 'location'],
|
||||||
|
equals: Prisma.JsonNull,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (args.includes('td_company')) {
|
||||||
|
conditions.push({
|
||||||
|
documentInfo: {
|
||||||
|
path: ['tdLocation'],
|
||||||
|
equals: 'company',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (args.includes('td_customer')) {
|
||||||
|
conditions.push({
|
||||||
|
documentInfo: {
|
||||||
|
path: ['tdLocation'],
|
||||||
|
equals: 'customer',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApprovalState,
|
||||||
|
Ticket,
|
||||||
|
TicketValidationState,
|
||||||
|
Tour,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
import { DocumentInfoArgs } from './ticket-arg.types';
|
||||||
|
import {
|
||||||
|
ValidationResult,
|
||||||
|
validatePatient,
|
||||||
|
validatePatientDataOnTours,
|
||||||
|
validateTour,
|
||||||
|
} from './ticket-validation.functions';
|
||||||
|
import { TicketRepository } from './ticket.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TicketLifecycleService {
|
||||||
|
private readonly logger = new Logger(TicketLifecycleService.name);
|
||||||
|
|
||||||
|
constructor(private readonly repository: TicketRepository) {}
|
||||||
|
|
||||||
|
async createTickets(interval: Interval): Promise<void> {
|
||||||
|
const tours = await this.repository.findTours(interval);
|
||||||
|
|
||||||
|
for (const tour of tours) {
|
||||||
|
if (tour.ticketId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ticketId = await this.repository.searchTicketIDForTour(tour);
|
||||||
|
if (ticketId) {
|
||||||
|
await this.repository.addToursToTicket(ticketId, [tour]);
|
||||||
|
} else {
|
||||||
|
await this.repository.createTicket([tour]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.validateCreatedTickets();
|
||||||
|
// await this.archiveClosedTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateDocumentInfoUpdate(
|
||||||
|
ticketId: string,
|
||||||
|
documentInfo: DocumentInfoArgs,
|
||||||
|
) {
|
||||||
|
const ticket = await this.repository.findById(ticketId);
|
||||||
|
|
||||||
|
switch (ticket.currentState) {
|
||||||
|
case TicketValidationState.USER_INTERACTION_REQUIRED:
|
||||||
|
const approvalStateUpdate = documentInfo.approval.isNeeded
|
||||||
|
? ApprovalState.REQUESTED
|
||||||
|
: ApprovalState.FREE;
|
||||||
|
|
||||||
|
let stateUpdate: TicketValidationState =
|
||||||
|
TicketValidationState.USER_INTERACTION_REQUIRED;
|
||||||
|
if (
|
||||||
|
documentInfo.tdLocation === 'company' &&
|
||||||
|
approvalStateUpdate === ApprovalState.FREE
|
||||||
|
)
|
||||||
|
stateUpdate = TicketValidationState.ARCHIVED;
|
||||||
|
else if (documentInfo.tdLocation === 'uncertain')
|
||||||
|
stateUpdate = TicketValidationState.TD_UNCERTAIN;
|
||||||
|
else stateUpdate = TicketValidationState.DOCUMENTS_MISSING;
|
||||||
|
|
||||||
|
return this.repository.updateTicket(ticketId, {
|
||||||
|
documentInfo,
|
||||||
|
approvalState: approvalStateUpdate,
|
||||||
|
state: stateUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
case TicketValidationState.TD_UNCERTAIN:
|
||||||
|
return this.repository.updateTicket(ticketId, {
|
||||||
|
documentInfo,
|
||||||
|
state:
|
||||||
|
ticket.approvalState === ApprovalState.FREE &&
|
||||||
|
documentInfo.tdLocation === 'company'
|
||||||
|
? TicketValidationState.ARCHIVED
|
||||||
|
: TicketValidationState.DOCUMENTS_MISSING,
|
||||||
|
});
|
||||||
|
case TicketValidationState.DOCUMENTS_MISSING:
|
||||||
|
return this.repository.updateTicket(ticketId, {
|
||||||
|
documentInfo,
|
||||||
|
...((ticket.approvalState === ApprovalState.FREE ||
|
||||||
|
documentInfo.approval.location === 'company') &&
|
||||||
|
documentInfo.tdLocation === 'company'
|
||||||
|
? { state: TicketValidationState.ARCHIVED }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateCreatedTickets(): Promise<void> {
|
||||||
|
const tickets = await this.repository.findAllCreated();
|
||||||
|
this.logger.debug(`Found ${tickets.length} with state CREATED`);
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const {
|
||||||
|
result: isValid,
|
||||||
|
errors,
|
||||||
|
notes,
|
||||||
|
} = await this.validateCreatedTicket(ticket);
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
const { result: approvalResult } = await this.isApprovalNeeded(ticket);
|
||||||
|
if (approvalResult) {
|
||||||
|
await this.repository.updateTicket(ticket.id, {
|
||||||
|
errors,
|
||||||
|
notes,
|
||||||
|
state: TicketValidationState.USER_INTERACTION_REQUIRED,
|
||||||
|
approvalState: ticket.tours.every(({ patientId }) => !patientId)
|
||||||
|
? ApprovalState.UNKNOWN
|
||||||
|
: ApprovalState.REQUIRED,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.repository.updateTicket(ticket.id, {
|
||||||
|
errors,
|
||||||
|
notes,
|
||||||
|
state: TicketValidationState.USER_INTERACTION_REQUIRED,
|
||||||
|
approvalState: ApprovalState.FREE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.repository.setActionablesOnTicket(ticket.id, errors, notes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async archiveClosedTickets() {
|
||||||
|
const closedTickets = await this.repository.findAllClosed();
|
||||||
|
for (const closedTicket of closedTickets) {
|
||||||
|
await this.repository.setStateOfTicket(
|
||||||
|
closedTicket.id,
|
||||||
|
TicketValidationState.ARCHIVED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateCreatedTicket(
|
||||||
|
ticket: Ticket & { tours: Tour[] },
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const patientIds = [
|
||||||
|
...new Set(ticket.tours.map(({ patientId }) => patientId)),
|
||||||
|
];
|
||||||
|
if (patientIds.length > 1) {
|
||||||
|
return {
|
||||||
|
result: false,
|
||||||
|
errors: ['Ticket: Die Touren umfassen mehr als einen Patienten'],
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [patientId] = patientIds;
|
||||||
|
if (!patientId && ticket.tours.at(0)?.type !== 'Spontan') {
|
||||||
|
return {
|
||||||
|
result: false,
|
||||||
|
errors: [],
|
||||||
|
notes: ['Patient: Der Patient ist nicht in DispoLive hinterlegt'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let patientResult: ValidationResult;
|
||||||
|
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
|
||||||
|
patientResult = validatePatientDataOnTours(ticket.tours);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const patient = await this.repository.findPatientById(patientId);
|
||||||
|
patientResult = validatePatient(patient);
|
||||||
|
} catch {
|
||||||
|
patientResult = validatePatientDataOnTours(ticket.tours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tourResults = ticket.tours.map((tour) => validateTour(tour));
|
||||||
|
|
||||||
|
return [patientResult, ...tourResults].reduce(
|
||||||
|
(result, results) => ({
|
||||||
|
result: result.result && results.result,
|
||||||
|
errors: [...result.errors, ...results.errors],
|
||||||
|
notes: [...result.notes, ...results.notes],
|
||||||
|
}),
|
||||||
|
{ result: true, errors: [], notes: [] } as ValidationResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isApprovalNeeded(
|
||||||
|
ticket: Ticket & { tours: Tour[] },
|
||||||
|
): Promise<ValidationResult> {
|
||||||
|
const approvalRequirements = await Promise.all(
|
||||||
|
ticket.tours.map((tour) => this.checkTourApprovalRequirement(tour)),
|
||||||
|
);
|
||||||
|
const isApprovalRequired = approvalRequirements.reduce(
|
||||||
|
(result, requirement) => result || requirement,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: isApprovalRequired,
|
||||||
|
errors: [],
|
||||||
|
notes:
|
||||||
|
isApprovalRequired && ticket.tours.every(({ patientId }) => !patientId)
|
||||||
|
? [
|
||||||
|
'Patient nicht in Stammdaten: Prüfen Sie ob eine Genehmigung benötigt wird',
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkTourApprovalRequirement(tour: Tour): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'Einweisung',
|
||||||
|
'Entlassung',
|
||||||
|
'Entlassung Vorbestellung',
|
||||||
|
'Konsil KH - Dia',
|
||||||
|
'Privatfahrt Rechnung',
|
||||||
|
'Tagesklinik',
|
||||||
|
'Verlegung',
|
||||||
|
].includes(tour.category) ||
|
||||||
|
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
tour.patientId &&
|
||||||
|
['BTW', 'TSW'].includes(tour.ordinanceType) &&
|
||||||
|
tour.category !== 'Dialyse'
|
||||||
|
) {
|
||||||
|
const patient = await this.repository.findPatientById(tour.patientId);
|
||||||
|
if (
|
||||||
|
['3', '4', '5'].includes(patient.careDegree) &&
|
||||||
|
(['aG', 'Bi', 'H'].includes(patient.disabilityMark1) ||
|
||||||
|
['aG', 'Bi', 'H'].includes(patient.disabilityMark2))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export enum TicketSystemEventTypes {
|
||||||
|
Validated = 'ticket-system.validated',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TicketSystemValidatedEvent {
|
||||||
|
constructor(public readonly count: number) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { Patient, Tour } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
result: boolean;
|
||||||
|
notes: string[];
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePatient(patient: Patient): ValidationResult {
|
||||||
|
const notes: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!patient?.name) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push('Patient: Vorname ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.surname) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push('Patient: Nachname ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.birthday) {
|
||||||
|
isValid = false;
|
||||||
|
notes.push('Patient: Geburtstag ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.healthinsurance) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push('Patient: Krankenkasse ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.careDegree) {
|
||||||
|
notes.push('Patient: Pflegegrad ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.disabilityMark1) {
|
||||||
|
notes.push('Patient: Merkzeichen 1 ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
if (!patient?.disabilityMark2) {
|
||||||
|
notes.push('Patient: Merkzeichen 2 ist nicht gefüllt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: isValid,
|
||||||
|
errors,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePatientDataOnTours(tours: Tour[]): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
for (const tour of tours) {
|
||||||
|
if (!tour.patientName) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(
|
||||||
|
`Tour ${tour.operationId}: Patient Vorname ist nicht gefüllt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tour.patientSurname) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(
|
||||||
|
`Tour ${tour.operationId}: Patient Nachname ist nicht gefüllt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tour.healthInsurance) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(
|
||||||
|
`Tour ${tour.operationId}: Patient Krankenversicherung ist nicht gefüllt`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: isValid,
|
||||||
|
errors,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTour(tour: Tour): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!tour.ordinanceType) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Verordnungsart ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (!tour.transportType) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Transportart ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (!tour.category) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Kategorie ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (!tour.startDate) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Startzeit ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (!tour.startStreet || !tour.startZip || !tour.startCity) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Start Adresse ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (!tour.targetStreet || !tour.targetZip || !tour.targetCity) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Ziel Adresse ist nicht gefüllt`);
|
||||||
|
}
|
||||||
|
if (tour.occupiedKm > 9990) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Besetzte Kilometer sind zu hoch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: isValid,
|
||||||
|
errors,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTransport(tour: Tour): ValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!tour.carName) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Fahrzeugname ist nicht angegeben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tour.driverName) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Fahrer ist nicht angegeben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tour.codriverName) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Beifahrer ist nicht angegeben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tour.startBegin) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Anfahrt-Stempel fehlt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tour.empty) {
|
||||||
|
isValid = false;
|
||||||
|
errors.push(`Tour ${tour.operationId}: Frei-Stempel fehlt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: isValid,
|
||||||
|
errors,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||||
|
import {
|
||||||
|
ApprovalState,
|
||||||
|
Prisma,
|
||||||
|
Ticket,
|
||||||
|
TicketValidationState,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { Paginated } from 'src/core/base/pagination.input';
|
||||||
|
|
||||||
|
@ObjectType('ApprovalInfo')
|
||||||
|
export class ApprovalInfoObjectType {
|
||||||
|
@Field({ nullable: true })
|
||||||
|
isNeeded?: boolean;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
whoRequested?: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
answeredFrom?: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
location?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('DocumentInfo')
|
||||||
|
export class DocumentInfoObjectType {
|
||||||
|
@Field(() => ApprovalInfoObjectType)
|
||||||
|
approval: ApprovalInfoObjectType;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
tdLocation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Ticket')
|
||||||
|
export class TicketObjectType implements Ticket {
|
||||||
|
@Field()
|
||||||
|
id: string;
|
||||||
|
@Field(() => TicketValidationState)
|
||||||
|
currentState: TicketValidationState;
|
||||||
|
@Field(() => ApprovalState, { nullable: true })
|
||||||
|
approvalState: ApprovalState | null;
|
||||||
|
@Field(() => [String])
|
||||||
|
errors: string[];
|
||||||
|
@Field(() => [String])
|
||||||
|
notes: string[];
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isUrgent: boolean;
|
||||||
|
@Field()
|
||||||
|
urgency: Date;
|
||||||
|
@Field(() => DocumentInfoObjectType, { nullable: true })
|
||||||
|
documentInfo: Prisma.JsonValue;
|
||||||
|
@Field()
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('PaginatedTicket')
|
||||||
|
export class PaginatedTicketObjectType extends Paginated(TicketObjectType) {}
|
||||||
|
|
||||||
|
@ObjectType('TicketsUpdated')
|
||||||
|
export class TicketsUpdatedObjectType {
|
||||||
|
@Field(() => Int)
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TicketStateMeta')
|
||||||
|
export class TicketStateMetaObjectType {
|
||||||
|
@Field(() => Int)
|
||||||
|
count: number;
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
approvalRequiredCount?: number;
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
approvalUnknownCount?: number;
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
tdNotCompanyCount?: number;
|
||||||
|
@Field(() => Int, { nullable: true })
|
||||||
|
approvalNotCompanyCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('TicketStateKeyFigures')
|
||||||
|
export class TicketStateKeyFiguresObjectType {
|
||||||
|
@Field(() => Int)
|
||||||
|
dwellTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(TicketValidationState, {
|
||||||
|
name: 'TicketValidationState',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerEnumType(ApprovalState, {
|
||||||
|
name: 'ApprovalState',
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,619 @@
|
||||||
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApprovalState,
|
||||||
|
Patient,
|
||||||
|
Prisma,
|
||||||
|
TicketValidationState,
|
||||||
|
Tour,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import {
|
||||||
|
differenceInBusinessDays,
|
||||||
|
endOfDay,
|
||||||
|
Interval,
|
||||||
|
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 { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTicketStateMeta(ticketState: TicketValidationState) {
|
||||||
|
const ticketWithCurrentStateAndTours: Prisma.TicketWhereInput = {
|
||||||
|
currentState: ticketState,
|
||||||
|
tours: { some: { check: 0 } },
|
||||||
|
};
|
||||||
|
const [
|
||||||
|
count,
|
||||||
|
approvalRequiredCount,
|
||||||
|
approvalUnknownCount,
|
||||||
|
tdNotCompanyCount,
|
||||||
|
approvalNotCompanyCount,
|
||||||
|
] = await this.prisma.$transaction([
|
||||||
|
this.prisma.ticket.count({
|
||||||
|
where: { ...ticketWithCurrentStateAndTours },
|
||||||
|
}),
|
||||||
|
this.prisma.ticket.aggregate({
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...ticketWithCurrentStateAndTours,
|
||||||
|
approvalState: ApprovalState.REQUIRED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.ticket.aggregate({
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...ticketWithCurrentStateAndTours,
|
||||||
|
approvalState: ApprovalState.UNKNOWN,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.ticket.aggregate({
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...ticketWithCurrentStateAndTours,
|
||||||
|
documentInfo: {
|
||||||
|
path: ['approval', 'location'],
|
||||||
|
not: 'company',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.ticket.aggregate({
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
...ticketWithCurrentStateAndTours,
|
||||||
|
documentInfo: {
|
||||||
|
path: ['tdLocation'],
|
||||||
|
not: 'company',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
approvalRequiredCount: approvalRequiredCount._count.id,
|
||||||
|
approvalUnknownCount: approvalUnknownCount._count.id,
|
||||||
|
tdNotCompanyCount: tdNotCompanyCount._count.id,
|
||||||
|
approvalNotCompanyCount: approvalNotCompanyCount._count.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async calculateTicketStateKeyFigures(ticketState: TicketValidationState) {
|
||||||
|
const ticketWithCurrentStateAndTours: Prisma.TicketWhereInput = {
|
||||||
|
tours: { some: { check: 0 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ticketWithState = await this.prisma.ticket.findMany({
|
||||||
|
where: { ...ticketWithCurrentStateAndTours },
|
||||||
|
include: {
|
||||||
|
state: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dwellTimesForTicketState = ticketWithState
|
||||||
|
.map(({ state }) => {
|
||||||
|
console.log(state);
|
||||||
|
const stateToCalculateIdx = state.findIndex(
|
||||||
|
({ state }) => state === ticketState,
|
||||||
|
);
|
||||||
|
if (stateToCalculateIdx > -1 && state[stateToCalculateIdx + 1]) {
|
||||||
|
const nextState = state[stateToCalculateIdx + 1];
|
||||||
|
return differenceInBusinessDays(
|
||||||
|
new Date(nextState.createdAt),
|
||||||
|
new Date(state[stateToCalculateIdx].createdAt),
|
||||||
|
);
|
||||||
|
} else if (stateToCalculateIdx > -1) {
|
||||||
|
return differenceInBusinessDays(
|
||||||
|
new Date(),
|
||||||
|
new Date(state[stateToCalculateIdx].createdAt),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dwellTime:
|
||||||
|
dwellTimesForTicketState.reduce((a, b) => a + b, 0) /
|
||||||
|
dwellTimesForTicketState.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
findPatientById(patientId: string): Promise<Patient> {
|
||||||
|
return this.prisma.patient.findUnique({ where: { id: patientId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findStatesByTicketId(
|
||||||
|
ticketId: string,
|
||||||
|
): Promise<{ state: TicketValidationState }[]> {
|
||||||
|
return this.prisma.ticketStateHistory.findMany({
|
||||||
|
where: {
|
||||||
|
ticketId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
state: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findToursByTicketId(ticketId: string): Promise<Tour[]> {
|
||||||
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
ticketId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchTicketIDForTour(tour: Tour): Promise<string | undefined> {
|
||||||
|
const tours = await Promise.all([
|
||||||
|
this.findFirstRecurringTour(tour),
|
||||||
|
this.findTourOnSameDayWithOppositeDirection(tour),
|
||||||
|
]);
|
||||||
|
const firstFoundTour = tours.filter((tour) => !!tour)[0];
|
||||||
|
if (firstFoundTour) {
|
||||||
|
const ticket = await this.prisma.ticket.findFirst({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
tours: {
|
||||||
|
some: {
|
||||||
|
id: firstFoundTour.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ticket?.id;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
findFirstRecurringTour(tour: Tour): Promise<Tour | null> {
|
||||||
|
const institutionStreet =
|
||||||
|
tour.direction === 'Hinfahrt' ? tour.targetStreet : tour.startStreet;
|
||||||
|
return this.prisma.tour.findFirst({
|
||||||
|
where: {
|
||||||
|
type: 'Serie',
|
||||||
|
category: tour.category,
|
||||||
|
ordinanceType: tour.ordinanceType,
|
||||||
|
patientId: tour.patientId,
|
||||||
|
patientName: tour.patientName,
|
||||||
|
patientSurname: tour.patientSurname,
|
||||||
|
patientStreet: tour.patientStreet,
|
||||||
|
patientCity: tour.patientCity,
|
||||||
|
patientZip: tour.patientZip,
|
||||||
|
OR: [
|
||||||
|
{ targetStreet: institutionStreet },
|
||||||
|
{ startStreet: institutionStreet },
|
||||||
|
],
|
||||||
|
rangeEndDate: {
|
||||||
|
gte: tour.startDate,
|
||||||
|
},
|
||||||
|
ticketId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
startDate: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findTourOnSameDayWithOppositeDirection(tour: Tour): Promise<Tour | null> {
|
||||||
|
return this.prisma.tour.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
gt: startOfDay(new Date(tour.startDate)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
lt: endOfDay(new Date(tour.startDate)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startStreet: tour.targetStreet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetStreet: tour.startStreet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: tour.direction === 'Hinfahrt' ? 'Rückfahrt' : 'Hinfahrt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findToursWithTicket(interval: Interval): Promise<Tour[]> {
|
||||||
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
ticketId: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
gte: new Date(interval.start),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
lt: new Date(interval.end),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeTourFromTicket(tourId: string): Promise<Tour> {
|
||||||
|
return this.prisma.tour.update({
|
||||||
|
where: {
|
||||||
|
id: tourId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
Ticket: {
|
||||||
|
disconnect: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findTours(interval: Interval): Promise<Tour[]> {
|
||||||
|
return this.prisma.tour.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
gte: new Date(interval.start),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startDate: {
|
||||||
|
lt: new Date(interval.end),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
carName: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Storno',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Löschen',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
carName: {
|
||||||
|
not: 'Abgabe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
check: {
|
||||||
|
not: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ticketId: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Pause
|
||||||
|
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
patientId: {
|
||||||
|
// Feierabend
|
||||||
|
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmptyTickets(): Promise<Prisma.BatchPayload> {
|
||||||
|
return this.prisma.ticket.deleteMany({
|
||||||
|
where: {
|
||||||
|
tours: {
|
||||||
|
none: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Mutation,
|
||||||
|
Parent,
|
||||||
|
Query,
|
||||||
|
ResolveField,
|
||||||
|
Resolver,
|
||||||
|
Subscription,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { TicketValidationState } from '@prisma/client';
|
||||||
|
import { TourObjectType } from '../app-accounting/tour.object-type';
|
||||||
|
import { DocumentInfoArgs, TicketsArgs } from './ticket-arg.types';
|
||||||
|
import {
|
||||||
|
PaginatedTicketObjectType,
|
||||||
|
TicketObjectType,
|
||||||
|
TicketStateKeyFiguresObjectType,
|
||||||
|
TicketStateMetaObjectType,
|
||||||
|
TicketsUpdatedObjectType,
|
||||||
|
} from './ticket.object-type';
|
||||||
|
import { TicketService } from './tickets.service';
|
||||||
|
|
||||||
|
@Resolver(() => TicketObjectType)
|
||||||
|
export class TicketsResolver {
|
||||||
|
constructor(private readonly ticketsService: TicketService) {}
|
||||||
|
|
||||||
|
@Query(() => TicketObjectType)
|
||||||
|
ticket(@Args('id') id: string) {
|
||||||
|
return this.ticketsService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => PaginatedTicketObjectType)
|
||||||
|
tickets(@Args() { state, filters, cursor, take }: TicketsArgs) {
|
||||||
|
return this.ticketsService.paginateTicketsByState(
|
||||||
|
state,
|
||||||
|
filters,
|
||||||
|
take,
|
||||||
|
cursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => [TicketObjectType])
|
||||||
|
searchTickets(@Args('query') query: string) {
|
||||||
|
return this.ticketsService.searchTickets(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => TicketStateMetaObjectType)
|
||||||
|
ticketStateMeta(
|
||||||
|
@Args('state', { type: () => TicketValidationState })
|
||||||
|
ticketState: TicketValidationState,
|
||||||
|
) {
|
||||||
|
return this.ticketsService.getTicketStateMeta(ticketState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => TicketStateKeyFiguresObjectType)
|
||||||
|
ticketStateKeyFigures(
|
||||||
|
@Args('state', { type: () => TicketValidationState })
|
||||||
|
ticketState: TicketValidationState,
|
||||||
|
) {
|
||||||
|
return this.ticketsService.getTicketStateKeyFigures(ticketState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => TicketObjectType)
|
||||||
|
setDocumentInfo(
|
||||||
|
@Args('ticketId', { type: () => String }) ticketId: string,
|
||||||
|
@Args() { approval, tdLocation }: DocumentInfoArgs,
|
||||||
|
) {
|
||||||
|
return this.ticketsService.updateDocumentInfo(ticketId, {
|
||||||
|
approval,
|
||||||
|
tdLocation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => [TourObjectType])
|
||||||
|
tours(@Parent() ticket: TicketObjectType) {
|
||||||
|
return this.ticketsService.findToursByTicketId(ticket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscription(() => TicketsUpdatedObjectType)
|
||||||
|
ticketsUpdated() {
|
||||||
|
return this.ticketsService.pubSub.asyncIterator('ticketsUpdated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
|
||||||
|
import { Interval } from 'date-fns';
|
||||||
|
import { PubSub } from 'graphql-subscriptions';
|
||||||
|
import {
|
||||||
|
decodeOffsetCursor,
|
||||||
|
encodeCursor,
|
||||||
|
} from 'src/core/base/pagination.util';
|
||||||
|
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
|
||||||
|
import { TicketLifecycleService } from './ticket-lifecycle.service';
|
||||||
|
import {
|
||||||
|
TicketSystemEventTypes,
|
||||||
|
TicketSystemValidatedEvent,
|
||||||
|
} from './ticket-system.events';
|
||||||
|
import {
|
||||||
|
PaginatedTicketObjectType,
|
||||||
|
TicketStateKeyFiguresObjectType,
|
||||||
|
TicketStateMetaObjectType,
|
||||||
|
} from './ticket.object-type';
|
||||||
|
import { TicketRepository } from './ticket.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TicketService {
|
||||||
|
public readonly pubSub = new PubSub();
|
||||||
|
|
||||||
|
@OnEvent(TicketSystemEventTypes.Validated, { async: true })
|
||||||
|
notifyOnTicketsValidated(event: TicketSystemValidatedEvent): void {
|
||||||
|
this.pubSub.publish('ticketsUpdated', {
|
||||||
|
ticketsUpdated: { count: event.count },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async paginateTicketsByState(
|
||||||
|
state: TicketValidationState,
|
||||||
|
filter: TicketFilterArgs,
|
||||||
|
take?: number,
|
||||||
|
cursor?: string,
|
||||||
|
): Promise<PaginatedTicketObjectType> {
|
||||||
|
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
|
||||||
|
const { tickets, total } = await this.repository.findByState(
|
||||||
|
state,
|
||||||
|
filter,
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevPage = skip - take < 0 ? 0 : skip - take;
|
||||||
|
const nextPage = skip + take < total ? skip + take : skip;
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: tickets,
|
||||||
|
prevCursor: encodeCursor(prevPage),
|
||||||
|
endCursor: encodeCursor(nextPage),
|
||||||
|
hasNextPage: nextPage !== skip,
|
||||||
|
totalCount: total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTicketStateMeta(
|
||||||
|
ticketState: TicketValidationState,
|
||||||
|
): Promise<TicketStateMetaObjectType> {
|
||||||
|
return this.repository.calculateTicketStateMeta(ticketState);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTicketStateKeyFigures(
|
||||||
|
ticketState: TicketValidationState,
|
||||||
|
): Promise<TicketStateKeyFiguresObjectType> {
|
||||||
|
return this.repository.calculateTicketStateKeyFigures(ticketState);
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,25 +15,28 @@ import { AnomalyTypes } from './anomaly-detectors/anomaly-types.enum';
|
||||||
import { DriverCertificateDetector } from './anomaly-detectors/driver-certificate.detector';
|
import { DriverCertificateDetector } from './anomaly-detectors/driver-certificate.detector';
|
||||||
import { DriverCodriverSameDetector } from './anomaly-detectors/driver-codriver-same.detector';
|
import { DriverCodriverSameDetector } from './anomaly-detectors/driver-codriver-same.detector';
|
||||||
import { EmployeesLoggedInDetector } from './anomaly-detectors/employees-logged-in.detector';
|
import { EmployeesLoggedInDetector } from './anomaly-detectors/employees-logged-in.detector';
|
||||||
import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
|
// import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
|
||||||
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 { AnomalyDetector } from './anomaly-detectors/anomaly-detector.class';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AnomaliesService {
|
export class AnomaliesService {
|
||||||
private detectors = [
|
private detectors: AnomalyDetector[];
|
||||||
new EmployeesLoggedInDetector(this.anomalyDetectorService),
|
|
||||||
new DriverCertificateDetector(this.anomalyDetectorService),
|
|
||||||
new DriverCodriverSameDetector(this.anomalyDetectorService),
|
|
||||||
new OneTourAtATimeDetector(this.anomalyDetectorService),
|
|
||||||
new WorkingEmployeeNotInDistributionSystemDetector(
|
|
||||||
this.anomalyDetectorService,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly anomalyDetectorService: AnomalyDetectorService,
|
private readonly anomalyDetectorService: AnomalyDetectorService,
|
||||||
) {}
|
) {
|
||||||
|
this.detectors = [
|
||||||
|
new EmployeesLoggedInDetector(this.anomalyDetectorService),
|
||||||
|
new DriverCertificateDetector(this.anomalyDetectorService),
|
||||||
|
new DriverCodriverSameDetector(this.anomalyDetectorService),
|
||||||
|
// new OneTourAtATimeDetector(this.anomalyDetectorService),
|
||||||
|
new WorkingEmployeeNotInDistributionSystemDetector(
|
||||||
|
this.anomalyDetectorService,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
detectAnomalies(interval: Interval): Promise<Anomaly[]> {
|
detectAnomalies(interval: Interval): Promise<Anomaly[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ export class AnomalyDetectorService {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
orderBy: {
|
||||||
|
startDate: 'asc',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -267,19 +267,6 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
|
||||||
|
|
||||||
const datetimeToCheckWithBuffer = addMinutes(datetimeToCheck, 5);
|
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 {
|
try {
|
||||||
if (workTime) {
|
if (workTime) {
|
||||||
const interval = {
|
const interval = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { Anomaly, Prisma } from '@prisma/client';
|
import { Anomaly, Prisma, Tour } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
areIntervalsOverlapping,
|
areIntervalsOverlapping,
|
||||||
|
|
@ -7,11 +7,11 @@ import {
|
||||||
format,
|
format,
|
||||||
Interval,
|
Interval,
|
||||||
isBefore,
|
isBefore,
|
||||||
isWithinInterval,
|
isSameDay,
|
||||||
set,
|
set,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
|
import { fromZonedTime, toZonedTime } from 'date-fns-tz';
|
||||||
|
|
||||||
import { AnomalyDetector } from './anomaly-detector.class';
|
import { AnomalyDetector } from './anomaly-detector.class';
|
||||||
import { AnomalyTypes } from './anomaly-types.enum';
|
import { AnomalyTypes } from './anomaly-types.enum';
|
||||||
|
|
@ -20,10 +20,25 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
private readonly logger = new Logger(OneTourAtATimeDetector.name);
|
private readonly logger = new Logger(OneTourAtATimeDetector.name);
|
||||||
readonly type = AnomalyTypes.OneTourAtATime;
|
readonly type = AnomalyTypes.OneTourAtATime;
|
||||||
|
|
||||||
|
private groupToursByDay(tours: Tour[]): Record<string, Tour[]> {
|
||||||
|
return tours.reduce(
|
||||||
|
(acc, cur) => {
|
||||||
|
const dayString = new Date(cur.startDate).toDateString();
|
||||||
|
const group = acc[dayString] ?? [];
|
||||||
|
group.push(cur);
|
||||||
|
return { ...acc, [dayString]: group };
|
||||||
|
},
|
||||||
|
{} as Record<string, Tour[]>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
|
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
|
||||||
const toursInRange = await this.dataService.findToursInInterval(inInterval);
|
const toursInRange = await this.dataService.findToursInInterval(inInterval);
|
||||||
|
|
||||||
return toursInRange
|
const toursMap = this.groupToursByDay(toursInRange);
|
||||||
|
const result = [];
|
||||||
|
for (const [, tours] of Object.entries(toursMap)) {
|
||||||
|
const intermediateStep = tours
|
||||||
.map(
|
.map(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -62,11 +77,12 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
const tourInterval = this.getTourInterval(start, end, startDate);
|
const tourInterval = this.getTourInterval(start, end, startDate);
|
||||||
|
|
||||||
const toursInInterval = tours
|
const toursInInterval = tours
|
||||||
.filter((tour) =>
|
.filter(
|
||||||
isWithinInterval(tour.startDate, {
|
(tour) => isSameDay(tour.startDate, startDate),
|
||||||
start: startOfDay(startDate),
|
// isWithinInterval(tour.startDate, {
|
||||||
end: endOfDay(startDate),
|
// start: startOfDay(startDate),
|
||||||
}),
|
// end: endOfDay(startDate),
|
||||||
|
// }),
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
({
|
({
|
||||||
|
|
@ -127,11 +143,18 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
);
|
);
|
||||||
|
|
||||||
const toursInIntervalWithDriver = toursInInterval.filter(
|
const toursInIntervalWithDriver = toursInInterval.filter(
|
||||||
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
|
({
|
||||||
driverId === tourListDriverId || driverId === tourListCodriverId,
|
driverId: tourListDriverId,
|
||||||
|
codriverId: tourListCodriverId,
|
||||||
|
}) =>
|
||||||
|
driverId === tourListDriverId ||
|
||||||
|
driverId === tourListCodriverId,
|
||||||
);
|
);
|
||||||
const toursInIntervalWithCodriver = toursInInterval.filter(
|
const toursInIntervalWithCodriver = toursInInterval.filter(
|
||||||
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
|
({
|
||||||
|
driverId: tourListDriverId,
|
||||||
|
codriverId: tourListCodriverId,
|
||||||
|
}) =>
|
||||||
codriverId === tourListDriverId ||
|
codriverId === tourListDriverId ||
|
||||||
codriverId === tourListCodriverId,
|
codriverId === tourListCodriverId,
|
||||||
);
|
);
|
||||||
|
|
@ -164,7 +187,10 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
operationId,
|
operationId,
|
||||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
startDate: format(
|
||||||
|
new Date(startDate),
|
||||||
|
'HH:mm dd.MM.yy',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
...overlappingTourInfoWithDriver,
|
...overlappingTourInfoWithDriver,
|
||||||
],
|
],
|
||||||
|
|
@ -180,7 +206,10 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
operationId,
|
operationId,
|
||||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
startDate: format(
|
||||||
|
new Date(startDate),
|
||||||
|
'HH:mm dd.MM.yy',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
...overlappingTourInfoWithCodriver,
|
...overlappingTourInfoWithCodriver,
|
||||||
],
|
],
|
||||||
|
|
@ -191,6 +220,9 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
||||||
)
|
)
|
||||||
.flat()
|
.flat()
|
||||||
.filter((anomaly) => !!anomaly);
|
.filter((anomaly) => !!anomaly);
|
||||||
|
result.push(intermediateStep);
|
||||||
|
}
|
||||||
|
return result.flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(anomaly: Anomaly): Promise<Anomaly> {
|
async validate(anomaly: Anomaly): Promise<Anomaly> {
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,7 @@ export class CalculationService {
|
||||||
): number {
|
): number {
|
||||||
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
|
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
|
||||||
// if TariffGroup is VDEK KTW, sum up costTypes
|
// if TariffGroup is VDEK KTW, sum up costTypes
|
||||||
const isVdek =
|
const isVdek = tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1;
|
||||||
tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 ?? false;
|
|
||||||
|
|
||||||
return tariff.costTypes.reduce((sum, costType) => {
|
return tariff.costTypes.reduce((sum, costType) => {
|
||||||
switch (costType.variant) {
|
switch (costType.variant) {
|
||||||
|
|
@ -281,12 +280,19 @@ export class CalculationService {
|
||||||
|
|
||||||
private calculateTSW(tour: Tour, costType: CostType): number {
|
private calculateTSW(tour: Tour, costType: CostType): number {
|
||||||
if (
|
if (
|
||||||
costType.description.includes('TSW 1') &&
|
costType.description.includes('TS+R') &&
|
||||||
|
tour.transportType === 'TS+R'
|
||||||
|
) {
|
||||||
|
return costType.costAmount;
|
||||||
|
} else if (
|
||||||
|
(costType.description.includes('TS 1') ||
|
||||||
|
costType.description.includes('TSW 1')) &&
|
||||||
tour.driverId === tour.codriverId
|
tour.driverId === tour.codriverId
|
||||||
) {
|
) {
|
||||||
return costType.costAmount;
|
return costType.costAmount;
|
||||||
} else if (
|
} else if (
|
||||||
costType.description.includes('TSW 2') &&
|
(costType.description.includes('TS 2') ||
|
||||||
|
costType.description.includes('TSW 2')) &&
|
||||||
tour.driverId !== tour.codriverId
|
tour.driverId !== tour.codriverId
|
||||||
) {
|
) {
|
||||||
return costType.costAmount;
|
return costType.costAmount;
|
||||||
|
|
@ -389,9 +395,16 @@ export class CalculationService {
|
||||||
}
|
}
|
||||||
const tenant = await this.prisma.avicennaTenant.findFirst({
|
const tenant = await this.prisma.avicennaTenant.findFirst({
|
||||||
where: {
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
name: healthInsurance,
|
||||||
|
},
|
||||||
|
{
|
||||||
name: {
|
name: {
|
||||||
contains: healthInsurance,
|
contains: healthInsurance,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
tariffGroups: {
|
tariffGroups: {
|
||||||
some: {
|
some: {
|
||||||
ordinanceType,
|
ordinanceType,
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ import { PersonnelCostCalculationService } from './personnel-cost-calculation.se
|
||||||
providers: [CalculationService, PersonnelCostCalculationService],
|
providers: [CalculationService, PersonnelCostCalculationService],
|
||||||
exports: [CalculationService],
|
exports: [CalculationService],
|
||||||
})
|
})
|
||||||
export class UtilCalculationModule {}
|
export class FeatureCalculationModule {}
|
||||||
|
|
@ -19,4 +19,21 @@ export const INFECTION_TYPES = {
|
||||||
Norovirus: '2.B',
|
Norovirus: '2.B',
|
||||||
Sonstiges: '2.A',
|
Sonstiges: '2.A',
|
||||||
VRE: '2.A',
|
VRE: '2.A',
|
||||||
|
|
||||||
|
// TODO: Veränderte Infektionstypen ergänzen
|
||||||
|
// Diphtherie: '',
|
||||||
|
// Harnwegsinfektion: '',
|
||||||
|
// 'Hepatitis A,E': '',
|
||||||
|
// 'Hepatitis B,C,D': '',
|
||||||
|
// Influenza: '',
|
||||||
|
// 'Krätze 3.1': '',
|
||||||
|
// Laus: '',
|
||||||
|
// MRGN: '',
|
||||||
|
// 'MRGN (MNR)': '',
|
||||||
|
// 'MRSA (MNR)': '',
|
||||||
|
// 'Röteln (Rubeolae)': '',
|
||||||
|
// Scharlach: '',
|
||||||
|
// 'TBC Lunge': '',
|
||||||
|
// 'TBC Organ': '',
|
||||||
|
// 'VRE (MNR)': '',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { FeatureImporterModule } from './feat-importers/feature-importer.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [FeatureImporterModule],
|
||||||
|
exports: [FeatureImporterModule],
|
||||||
|
})
|
||||||
|
export class FeatureEtlModule {}
|
||||||
|
|
@ -320,7 +320,11 @@ export class DispoLiveExtractorsService {
|
||||||
surname,
|
surname,
|
||||||
persId,
|
persId,
|
||||||
importPersId,
|
importPersId,
|
||||||
qualifikation,
|
qualifikation:
|
||||||
|
!!qualifikation && typeof qualifikation === 'object'
|
||||||
|
? //@ts-expect-error ts is wrong here
|
||||||
|
qualifikation?._id
|
||||||
|
: qualifikation ?? undefined,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export enum EventTypes {
|
export enum ETLEventTypes {
|
||||||
ExtractionDone = 'etl.extraction',
|
ExtractionDone = 'etl.extraction',
|
||||||
TransformationDone = 'etl.transformation',
|
TransformationDone = 'etl.transformation',
|
||||||
LoadingDone = 'etl.loading',
|
LoadingDone = 'etl.loading',
|
||||||
|
|
@ -7,7 +7,7 @@ import { Observable, combineLatest, from, map, switchMap, tap } from 'rxjs';
|
||||||
import { ExtractorsService } from '../feat-extractors/extractors.service';
|
import { ExtractorsService } from '../feat-extractors/extractors.service';
|
||||||
import { LoadersService } from '../feat-loaders/loaders.service';
|
import { LoadersService } from '../feat-loaders/loaders.service';
|
||||||
import { TransformersService } from '../feat-transformers/transformers.service';
|
import { TransformersService } from '../feat-transformers/transformers.service';
|
||||||
import { EventTypes } from './events/event-types.enum';
|
import { ETLEventTypes } from './events/event-types.enum';
|
||||||
import { ExtractionDoneEvent } from './events/extraction-done.event';
|
import { ExtractionDoneEvent } from './events/extraction-done.event';
|
||||||
import { LoadingDoneEvent } from './events/loading-done.event';
|
import { LoadingDoneEvent } from './events/loading-done.event';
|
||||||
import { TransformationDoneEvent } from './events/transformation-done.event';
|
import { TransformationDoneEvent } from './events/transformation-done.event';
|
||||||
|
|
@ -62,7 +62,7 @@ export class ImporterService {
|
||||||
]).pipe(
|
]).pipe(
|
||||||
tap(() =>
|
tap(() =>
|
||||||
this.eventEmitter.emit(
|
this.eventEmitter.emit(
|
||||||
EventTypes.ExtractionDone,
|
ETLEventTypes.ExtractionDone,
|
||||||
new ExtractionDoneEvent(jobId),
|
new ExtractionDoneEvent(jobId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -84,7 +84,7 @@ export class ImporterService {
|
||||||
]).pipe(
|
]).pipe(
|
||||||
tap(() =>
|
tap(() =>
|
||||||
this.eventEmitter.emit(
|
this.eventEmitter.emit(
|
||||||
EventTypes.TransformationDone,
|
ETLEventTypes.TransformationDone,
|
||||||
new TransformationDoneEvent(jobId),
|
new TransformationDoneEvent(jobId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -104,7 +104,7 @@ export class ImporterService {
|
||||||
switchMap(() => this.loadersService.loadWorkTimes()),
|
switchMap(() => this.loadersService.loadWorkTimes()),
|
||||||
tap(() =>
|
tap(() =>
|
||||||
this.eventEmitter.emit(
|
this.eventEmitter.emit(
|
||||||
EventTypes.LoadingDone,
|
ETLEventTypes.LoadingDone,
|
||||||
new LoadingDoneEvent(jobId),
|
new LoadingDoneEvent(jobId),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
import { Interval } from 'date-fns';
|
import { Interval } from 'date-fns';
|
||||||
|
|
||||||
import { PrismaService } from 'src/core/database/prisma.service';
|
import { PrismaService } from 'src/core/database/prisma.service';
|
||||||
|
import { chunk } from 'src/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FinalRepository {
|
export class FinalRepository {
|
||||||
|
|
@ -50,16 +51,21 @@ export class FinalRepository {
|
||||||
|
|
||||||
createTours(
|
createTours(
|
||||||
tourCreateInput: Prisma.TourCreateInput[],
|
tourCreateInput: Prisma.TourCreateInput[],
|
||||||
): Promise<Prisma.BatchPayload> {
|
): Promise<Prisma.BatchPayload[]> {
|
||||||
return this.prisma.tour.createMany({
|
const chunks = chunk(tourCreateInput, 25000);
|
||||||
data: tourCreateInput,
|
return Promise.all(
|
||||||
|
chunks.map((chunk) =>
|
||||||
|
this.prisma.tour.createMany({
|
||||||
|
data: chunk,
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTours(
|
async updateTours(
|
||||||
tourCreateInput: Prisma.TourCreateInput[],
|
tourCreateInput: Prisma.TourCreateInput[],
|
||||||
): Promise<Prisma.BatchPayload> {
|
): Promise<Prisma.BatchPayload[]> {
|
||||||
await this.prisma.tour.deleteMany({
|
await this.prisma.tour.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
operationId: {
|
operationId: {
|
||||||
|
|
@ -70,17 +76,22 @@ export class FinalRepository {
|
||||||
return this.createTours(tourCreateInput);
|
return this.createTours(tourCreateInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload> {
|
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload[]> {
|
||||||
return this.prisma.tour.updateMany({
|
const chunks = chunk(operationIds, 20000);
|
||||||
|
return Promise.all(
|
||||||
|
chunks.map((chunk) =>
|
||||||
|
this.prisma.tour.updateMany({
|
||||||
where: {
|
where: {
|
||||||
operationId: {
|
operationId: {
|
||||||
in: operationIds,
|
in: chunk,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
findPatients(): Promise<Patient[]> {
|
findPatients(): Promise<Patient[]> {
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
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 { UtilCalculationModule } from '../feat-calculation/util-calculation.module';
|
import { FeatureCalculationModule } from 'src/modules/feat-calculation/feat-calculation.module';
|
||||||
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
|
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
|
||||||
import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
|
import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
|
||||||
import { StagingRepository } from './staging.repository';
|
import { StagingRepository } from './staging.repository';
|
||||||
import { TransformersService } from './transformers.service';
|
import { TransformersService } from './transformers.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, DataAccessExtractorsModule, UtilCalculationModule],
|
imports: [PrismaModule, DataAccessExtractorsModule, FeatureCalculationModule],
|
||||||
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
|
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
|
||||||
exports: [TransformersService],
|
exports: [TransformersService],
|
||||||
})
|
})
|
||||||
|
|
@ -2,7 +2,7 @@ import { parse, parseISO } from 'date-fns';
|
||||||
import { fromZonedTime } from 'date-fns-tz';
|
import { fromZonedTime } from 'date-fns-tz';
|
||||||
|
|
||||||
export const parseDateTime = (format: string) => (value: string) =>
|
export const parseDateTime = (format: string) => (value: string) =>
|
||||||
parse(value, format, new Date());
|
!!value ? parse(value, format, new Date()) : value;
|
||||||
export const parseIsoDateTime = (value: string) => parseISO(value);
|
export const parseIsoDateTime = (value: string) => parseISO(value);
|
||||||
|
|
||||||
export const parseZonedDateTime =
|
export const parseZonedDateTime =
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { PatientSource } from '@prisma/client';
|
import { PatientSource } from '@prisma/client';
|
||||||
|
import { fakerDE as faker } from '@faker-js/faker';
|
||||||
import { isValidDate } from 'src/utils';
|
import { isValidDate } from 'src/utils';
|
||||||
import { PatientPreStaging } from '../staging.interface';
|
import { PatientPreStaging } from '../staging.interface';
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,6 +12,9 @@ export class PatientTransformer {
|
||||||
public static transform(patientSource: PatientSource): PatientPreStaging {
|
public static transform(patientSource: PatientSource): PatientPreStaging {
|
||||||
const patientParsed = {
|
const patientParsed = {
|
||||||
...patientSource,
|
...patientSource,
|
||||||
|
surname: faker.person.lastName(),
|
||||||
|
name: faker.person.firstName(),
|
||||||
|
street: faker.location.streetAddress(),
|
||||||
...transformProperties(parseDateTime('yyyy-MM-dd'))(
|
...transformProperties(parseDateTime('yyyy-MM-dd'))(
|
||||||
pick(['birthday'])(patientSource),
|
pick(['birthday'])(patientSource),
|
||||||
),
|
),
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { PlannedTimeSource } from '@prisma/client';
|
import { PlannedTimeSource } from '@prisma/client';
|
||||||
|
import { fakerDE as faker } from '@faker-js/faker';
|
||||||
import { PlannedTimePreStaging } from '../staging.interface';
|
import { PlannedTimePreStaging } from '../staging.interface';
|
||||||
import {
|
import {
|
||||||
parseBoolean,
|
parseBoolean,
|
||||||
|
|
@ -14,6 +15,8 @@ export class PlannedTimeTransformer {
|
||||||
): PlannedTimePreStaging {
|
): PlannedTimePreStaging {
|
||||||
const plannedTimeParsed = {
|
const plannedTimeParsed = {
|
||||||
...plannedTimeSource,
|
...plannedTimeSource,
|
||||||
|
firstname: faker.person.firstName(),
|
||||||
|
surname: faker.person.lastName(),
|
||||||
...transformProperties(parseInteger)(
|
...transformProperties(parseInteger)(
|
||||||
pick(['id', 'user_id'])(plannedTimeSource),
|
pick(['id', 'user_id'])(plannedTimeSource),
|
||||||
),
|
),
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { TourSource } from '@prisma/client';
|
import { TourSource } from '@prisma/client';
|
||||||
|
import { fakerDE as faker } from '@faker-js/faker';
|
||||||
import { TourPreStaging } from '../staging.interface';
|
import { TourPreStaging } from '../staging.interface';
|
||||||
import {
|
import {
|
||||||
parseBoolean,
|
parseBoolean,
|
||||||
|
|
@ -15,6 +16,16 @@ export class TourTransformer {
|
||||||
public static transform(tourSource: TourSource): TourPreStaging {
|
public static transform(tourSource: TourSource): TourPreStaging {
|
||||||
const tourParsed = {
|
const tourParsed = {
|
||||||
...tourSource,
|
...tourSource,
|
||||||
|
operationId: faker.number.int({ min: 10000, max: 19999 }),
|
||||||
|
driverName: faker.person.fullName(),
|
||||||
|
codriverName: faker.person.fullName(),
|
||||||
|
patientName: faker.person.firstName(),
|
||||||
|
patientSurname: faker.person.lastName(),
|
||||||
|
patientStreet: faker.location.streetAddress(),
|
||||||
|
startInstitution: faker.company.name(),
|
||||||
|
startStreet: faker.location.streetAddress(),
|
||||||
|
targetInstitution: faker.company.name(),
|
||||||
|
targetStreet: faker.location.streetAddress(),
|
||||||
...transformProperties(parseInteger)(pick(['check'])(tourSource)),
|
...transformProperties(parseInteger)(pick(['check'])(tourSource)),
|
||||||
...transformProperties(parseFloat)(
|
...transformProperties(parseFloat)(
|
||||||
pick(['besetztKm', 'gesamtPreis'])(tourSource),
|
pick(['besetztKm', 'gesamtPreis'])(tourSource),
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { WorkTimeSource } from '@prisma/client';
|
import { WorkTimeSource } from '@prisma/client';
|
||||||
|
import { fakerDE as faker } from '@faker-js/faker';
|
||||||
import { WorkTimePreStaging } from '../staging.interface';
|
import { WorkTimePreStaging } from '../staging.interface';
|
||||||
import {
|
import {
|
||||||
parseCETDateTime,
|
parseCETDateTime,
|
||||||
|
|
@ -11,6 +12,8 @@ export class WorkTimeTransformer {
|
||||||
public static transform(workTimeSource: WorkTimeSource): WorkTimePreStaging {
|
public static transform(workTimeSource: WorkTimeSource): WorkTimePreStaging {
|
||||||
const workTimeParsed = {
|
const workTimeParsed = {
|
||||||
...workTimeSource,
|
...workTimeSource,
|
||||||
|
firstname: faker.person.firstName(),
|
||||||
|
surname: faker.person.lastName(),
|
||||||
...transformProperties(parseInteger)(
|
...transformProperties(parseInteger)(
|
||||||
pick(['id', 'user_id'])(workTimeSource),
|
pick(['id', 'user_id'])(workTimeSource),
|
||||||
),
|
),
|
||||||
|
|
@ -22,12 +22,14 @@ export class TicketLifecycleService {
|
||||||
constructor(private readonly repository: TicketRepository) {}
|
constructor(private readonly repository: TicketRepository) {}
|
||||||
|
|
||||||
async createTickets(interval: Interval): Promise<void> {
|
async createTickets(interval: Interval): Promise<void> {
|
||||||
|
try {
|
||||||
const tours = await this.repository.findTours(interval);
|
const tours = await this.repository.findTours(interval);
|
||||||
|
|
||||||
for (const tour of tours) {
|
for (const tour of tours) {
|
||||||
if (tour.ticketId) {
|
if (tour.ticketId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketId = await this.repository.searchTicketIDForTour(tour);
|
const ticketId = await this.repository.searchTicketIDForTour(tour);
|
||||||
if (ticketId) {
|
if (ticketId) {
|
||||||
await this.repository.addToursToTicket(ticketId, [tour]);
|
await this.repository.addToursToTicket(ticketId, [tour]);
|
||||||
|
|
@ -38,6 +40,9 @@ export class TicketLifecycleService {
|
||||||
|
|
||||||
await this.validateCreatedTickets();
|
await this.validateCreatedTickets();
|
||||||
// await this.archiveClosedTickets();
|
// await this.archiveClosedTickets();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateDocumentInfoUpdate(
|
async validateDocumentInfoUpdate(
|
||||||
|
|
@ -162,11 +167,11 @@ export class TicketLifecycleService {
|
||||||
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
|
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
|
||||||
patientResult = validatePatientDataOnTours(ticket.tours);
|
patientResult = validatePatientDataOnTours(ticket.tours);
|
||||||
} else {
|
} else {
|
||||||
try {
|
|
||||||
const patient = await this.repository.findPatientById(patientId);
|
const patient = await this.repository.findPatientById(patientId);
|
||||||
patientResult = validatePatient(patient);
|
if (!patient) {
|
||||||
} catch {
|
|
||||||
patientResult = validatePatientDataOnTours(ticket.tours);
|
patientResult = validatePatientDataOnTours(ticket.tours);
|
||||||
|
} else {
|
||||||
|
patientResult = validatePatient(patient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const tourResults = ticket.tours.map((tour) => validateTour(tour));
|
const tourResults = ticket.tours.map((tour) => validateTour(tour));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export class TicketRepository {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createTicket(tours: Tour[]) {
|
async createTicket(tours: Tour[]) {
|
||||||
|
this.logger.log(`Creating Ticket for ${tours.length} tours`);
|
||||||
|
try {
|
||||||
const ticket = await this.db
|
const ticket = await this.db
|
||||||
.insertInto('Ticket')
|
.insertInto('Ticket')
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -59,6 +61,9 @@ export class TicketRepository {
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return ticket;
|
return ticket;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addToursToTicket(ticketId: string, tours: Tour[]) {
|
async addToursToTicket(ticketId: string, tours: Tour[]) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { groupBy } from './group-by';
|
||||||
|
|
||||||
|
describe('groupBy', () => {
|
||||||
|
it('should group a list by keys', () => {
|
||||||
|
const given = ['one', 'two', 'three'];
|
||||||
|
|
||||||
|
const when = groupBy(given, (item) => item.length);
|
||||||
|
|
||||||
|
expect(when).toEqual({
|
||||||
|
3: ['one', 'two'],
|
||||||
|
5: ['three'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue