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
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install \
|
||||
&& npm run build
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
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
|
||||
|
||||
COPY --from=builder /app/builder/node_modules ./node_modules
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ services:
|
|||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
|
||||
POSTGRES_USER: ${DATABASE_USERNAME}
|
||||
POSTGRES_DB: ${DATABASE_DATABASE}
|
||||
POSTGRES_HOST_AUTH_METHOD: password
|
||||
PG_DATA: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- ./.local/pgdata:/var/lib/postgresql/data
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.10.5",
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@nestjs/apollo": "^12.2.0",
|
||||
"@nestjs/axios": "^3.0.2",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/graphql": "^12.2.0",
|
||||
"@nestjs/platform-express": "^10.0.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",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.1.3",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"kysely": "^0.27.4",
|
||||
"pg": "^8.12.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
|
|
@ -61,7 +75,7 @@
|
|||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.1.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const KTW_AOK_TARIFFS = [
|
|||
'1/KTW-04',
|
||||
'1/KTW-05',
|
||||
'1/KTW-06',
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Leitstellenmanagement',
|
||||
|
|
@ -241,7 +241,7 @@ export const KTW_AOK_TARIFFS = [
|
|||
'1/KTW-04',
|
||||
'1/KTW-05-E',
|
||||
'1/KTW-06',
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'Leitstellenmanagement',
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ export const KTW_VDEK_TARIFFS = [
|
|||
{
|
||||
description: 'VDEK KTW',
|
||||
validFrom: startOfDay(new Date('2023-05-01')),
|
||||
validTo: null,
|
||||
validTo: endOfDay(new Date('2024-04-31')),
|
||||
costTypes: [
|
||||
{
|
||||
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',
|
||||
validFrom: startOfDay(new Date('2023-06-01')),
|
||||
validTo: null,
|
||||
validTo: endOfDay(new Date('2024-08-31')),
|
||||
costTypes: [
|
||||
{
|
||||
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 { 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()
|
||||
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')
|
||||
etl(@Query('from') from: string, @Query('to') to: string) {
|
||||
|
|
@ -19,9 +29,124 @@ export class AppController {
|
|||
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 { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PostgresDialect } from 'kysely';
|
||||
import { join } from 'path';
|
||||
import { Pool } from 'pg';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { PrismaModule } from './core/database/prisma.module';
|
||||
import { FeatureImporterModule } from './modules/feat-importers/feature-importer.module';
|
||||
import { ConfigModule } from './core/config/config.module';
|
||||
import { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||
import { DatabaseModule } from './core/database/database.module';
|
||||
import { PostgresDialect } from 'kysely';
|
||||
import { Pool } from 'pg';
|
||||
import { PrismaModule } from './core/database/prisma.module';
|
||||
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 { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||
import { FeatureCalculationModule } from './modules/feat-calculation/feat-calculation.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
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({
|
||||
// isGlobal: true,
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
host: 'localhost',
|
||||
host: 'db',
|
||||
port: 5432,
|
||||
database: 'avicenna',
|
||||
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(),
|
||||
ScheduleModule.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,
|
||||
FeatureCalculationModule,
|
||||
TicketSystemModule,
|
||||
AnomalyDetectionModule,
|
||||
ApplicationManagerModule,
|
||||
ApplicationAccountingModule,
|
||||
ApplicationControlCenterModule,
|
||||
ApplicationTicketSystemModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
startOfDay,
|
||||
} from 'date-fns';
|
||||
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 { 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 {
|
||||
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 { createKyselyProvider } from './kysely-client.factory';
|
||||
|
||||
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()
|
||||
@Module({})
|
||||
|
|
@ -16,4 +41,83 @@ export class DatabaseModule {
|
|||
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 { Prisma } from "@prisma/client";
|
||||
import { ModuleMetadata, Type } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
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() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableCors();
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
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 { DriverCodriverSameDetector } from './anomaly-detectors/driver-codriver-same.detector';
|
||||
import { EmployeesLoggedInDetector } from './anomaly-detectors/employees-logged-in.detector';
|
||||
import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
|
||||
// import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
|
||||
import { WorkingEmployeeNotInDistributionSystemDetector } from './anomaly-detectors/working-employee-not-in-distribution-system.detector';
|
||||
import { AnomalyDetector } from './anomaly-detectors/anomaly-detector.class';
|
||||
|
||||
@Injectable()
|
||||
export class AnomaliesService {
|
||||
private detectors = [
|
||||
new EmployeesLoggedInDetector(this.anomalyDetectorService),
|
||||
new DriverCertificateDetector(this.anomalyDetectorService),
|
||||
new DriverCodriverSameDetector(this.anomalyDetectorService),
|
||||
new OneTourAtATimeDetector(this.anomalyDetectorService),
|
||||
new WorkingEmployeeNotInDistributionSystemDetector(
|
||||
this.anomalyDetectorService,
|
||||
),
|
||||
];
|
||||
private detectors: AnomalyDetector[];
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
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[]> {
|
||||
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);
|
||||
|
||||
if (personnelNumber === '23') {
|
||||
console.log(
|
||||
datetimeToCheck,
|
||||
{
|
||||
start: new Date(workTime.startDate),
|
||||
end: new Date(workTime.endDate),
|
||||
},
|
||||
{
|
||||
start: new Date(firstClockedIn.dateTime),
|
||||
end: new Date(lastClockedOut.dateTime),
|
||||
},
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (workTime) {
|
||||
const interval = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import { Anomaly, Prisma } from '@prisma/client';
|
||||
import { Anomaly, Prisma, Tour } from '@prisma/client';
|
||||
import {
|
||||
addDays,
|
||||
areIntervalsOverlapping,
|
||||
|
|
@ -7,11 +7,11 @@ import {
|
|||
format,
|
||||
Interval,
|
||||
isBefore,
|
||||
isWithinInterval,
|
||||
isSameDay,
|
||||
set,
|
||||
startOfDay,
|
||||
} from 'date-fns';
|
||||
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
|
||||
import { fromZonedTime, toZonedTime } from 'date-fns-tz';
|
||||
|
||||
import { AnomalyDetector } from './anomaly-detector.class';
|
||||
import { AnomalyTypes } from './anomaly-types.enum';
|
||||
|
|
@ -20,177 +20,209 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
|
|||
private readonly logger = new Logger(OneTourAtATimeDetector.name);
|
||||
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[]> {
|
||||
const toursInRange = await this.dataService.findToursInInterval(inInterval);
|
||||
|
||||
return toursInRange
|
||||
.map(
|
||||
(
|
||||
{
|
||||
operationId,
|
||||
startDate,
|
||||
startBegin,
|
||||
startEnd,
|
||||
targetBegin,
|
||||
target,
|
||||
empty,
|
||||
carName,
|
||||
driverId,
|
||||
driverName,
|
||||
codriverId,
|
||||
codriverName,
|
||||
},
|
||||
_,
|
||||
tours,
|
||||
) => {
|
||||
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
|
||||
const [start] = [startBegin, startEnd].sort();
|
||||
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
|
||||
const [end] = [targetBegin, target, empty]
|
||||
.filter((val) => !!val)
|
||||
.sort()
|
||||
.reverse();
|
||||
const toursMap = this.groupToursByDay(toursInRange);
|
||||
const result = [];
|
||||
for (const [, tours] of Object.entries(toursMap)) {
|
||||
const intermediateStep = tours
|
||||
.map(
|
||||
(
|
||||
{
|
||||
operationId,
|
||||
startDate,
|
||||
startBegin,
|
||||
startEnd,
|
||||
targetBegin,
|
||||
target,
|
||||
empty,
|
||||
carName,
|
||||
driverId,
|
||||
driverName,
|
||||
codriverId,
|
||||
codriverName,
|
||||
},
|
||||
_,
|
||||
tours,
|
||||
) => {
|
||||
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
|
||||
const [start] = [startBegin, startEnd].sort();
|
||||
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
|
||||
const [end] = [targetBegin, target, empty]
|
||||
.filter((val) => !!val)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (!start || !end) {
|
||||
if (isBefore(new Date(), startDate)) {
|
||||
this.logger.warn(
|
||||
`Tour ${operationId} started in the past, but is missing start or end time`,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const tourInterval = this.getTourInterval(start, end, startDate);
|
||||
|
||||
const toursInInterval = tours
|
||||
.filter((tour) =>
|
||||
isWithinInterval(tour.startDate, {
|
||||
start: startOfDay(startDate),
|
||||
end: endOfDay(startDate),
|
||||
}),
|
||||
)
|
||||
.filter(
|
||||
({
|
||||
operationId: tourListOperationId,
|
||||
carName: tourListCarName,
|
||||
}) =>
|
||||
tourListOperationId !== operationId &&
|
||||
carName !== tourListCarName,
|
||||
)
|
||||
.filter(
|
||||
({
|
||||
startDate: tourListStartDate,
|
||||
startBegin: tourListStartBegin,
|
||||
startEnd: tourListStartEnd,
|
||||
targetBegin: tourListTargetBegin,
|
||||
target: tourListTarget,
|
||||
empty: tourListEmpty,
|
||||
}) => {
|
||||
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
|
||||
const [tourListStart] = [
|
||||
tourListStartBegin,
|
||||
tourListStartEnd,
|
||||
].sort();
|
||||
// somehow empty can be before everything else, so we use targetBegin and empty and take the higher value ...
|
||||
const [tourListEnd] = [
|
||||
tourListTargetBegin,
|
||||
tourListTarget,
|
||||
tourListEmpty,
|
||||
]
|
||||
.filter((val) => !!val)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (!tourListStart || !tourListEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tourListInterval = this.getTourInterval(
|
||||
tourListStart,
|
||||
tourListEnd,
|
||||
tourListStartDate,
|
||||
if (!start || !end) {
|
||||
if (isBefore(new Date(), startDate)) {
|
||||
this.logger.warn(
|
||||
`Tour ${operationId} started in the past, but is missing start or end time`,
|
||||
);
|
||||
try {
|
||||
return areIntervalsOverlapping(
|
||||
tourInterval,
|
||||
tourListInterval,
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const tourInterval = this.getTourInterval(start, end, startDate);
|
||||
|
||||
const toursInInterval = tours
|
||||
.filter(
|
||||
(tour) => isSameDay(tour.startDate, startDate),
|
||||
// isWithinInterval(tour.startDate, {
|
||||
// start: startOfDay(startDate),
|
||||
// end: endOfDay(startDate),
|
||||
// }),
|
||||
)
|
||||
.filter(
|
||||
({
|
||||
operationId: tourListOperationId,
|
||||
carName: tourListCarName,
|
||||
}) =>
|
||||
tourListOperationId !== operationId &&
|
||||
carName !== tourListCarName,
|
||||
)
|
||||
.filter(
|
||||
({
|
||||
startDate: tourListStartDate,
|
||||
startBegin: tourListStartBegin,
|
||||
startEnd: tourListStartEnd,
|
||||
targetBegin: tourListTargetBegin,
|
||||
target: tourListTarget,
|
||||
empty: tourListEmpty,
|
||||
}) => {
|
||||
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
|
||||
const [tourListStart] = [
|
||||
tourListStartBegin,
|
||||
tourListStartEnd,
|
||||
].sort();
|
||||
// somehow empty can be before everything else, so we use targetBegin and empty and take the higher value ...
|
||||
const [tourListEnd] = [
|
||||
tourListTargetBegin,
|
||||
tourListTarget,
|
||||
tourListEmpty,
|
||||
]
|
||||
.filter((val) => !!val)
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (!tourListStart || !tourListEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tourListInterval = this.getTourInterval(
|
||||
tourListStart,
|
||||
tourListEnd,
|
||||
tourListStartDate,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'tourListInterval',
|
||||
error,
|
||||
tourListInterval,
|
||||
tourInterval,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
try {
|
||||
return areIntervalsOverlapping(
|
||||
tourInterval,
|
||||
tourListInterval,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'tourListInterval',
|
||||
error,
|
||||
tourListInterval,
|
||||
tourInterval,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const toursInIntervalWithDriver = toursInInterval.filter(
|
||||
({
|
||||
driverId: tourListDriverId,
|
||||
codriverId: tourListCodriverId,
|
||||
}) =>
|
||||
driverId === tourListDriverId ||
|
||||
driverId === tourListCodriverId,
|
||||
);
|
||||
const toursInIntervalWithCodriver = toursInInterval.filter(
|
||||
({
|
||||
driverId: tourListDriverId,
|
||||
codriverId: tourListCodriverId,
|
||||
}) =>
|
||||
codriverId === tourListDriverId ||
|
||||
codriverId === tourListCodriverId,
|
||||
);
|
||||
|
||||
const toursInIntervalWithDriver = toursInInterval.filter(
|
||||
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
|
||||
driverId === tourListDriverId || driverId === tourListCodriverId,
|
||||
);
|
||||
const toursInIntervalWithCodriver = toursInInterval.filter(
|
||||
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
|
||||
codriverId === tourListDriverId ||
|
||||
codriverId === tourListCodriverId,
|
||||
);
|
||||
|
||||
const overlappingCarsWithDriver = toursInIntervalWithDriver.map(
|
||||
({ carName }) => carName,
|
||||
);
|
||||
const overlappingTourInfoWithDriver = toursInIntervalWithDriver.map(
|
||||
({ operationId, startDate }) => ({
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
operationId,
|
||||
}),
|
||||
);
|
||||
const overlappingCarsWithCodriver = toursInIntervalWithCodriver.map(
|
||||
({ carName }) => carName,
|
||||
);
|
||||
const overlappingTourInfoWithCodriver =
|
||||
toursInIntervalWithCodriver.map(({ operationId, startDate }) => ({
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
operationId,
|
||||
}));
|
||||
return [
|
||||
toursInIntervalWithDriver.length
|
||||
? this.createAnomaly(
|
||||
`${operationId}-${driverId}-${this.type}`,
|
||||
operationId,
|
||||
driverName,
|
||||
startDate,
|
||||
[carName, ...overlappingCarsWithDriver],
|
||||
[
|
||||
{
|
||||
operationId,
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
},
|
||||
...overlappingTourInfoWithDriver,
|
||||
],
|
||||
)
|
||||
: undefined,
|
||||
toursInIntervalWithCodriver.length
|
||||
? this.createAnomaly(
|
||||
`${operationId}-${codriverId}-${this.type}`,
|
||||
operationId,
|
||||
codriverName,
|
||||
startDate,
|
||||
[carName, ...overlappingCarsWithCodriver],
|
||||
[
|
||||
{
|
||||
operationId,
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
},
|
||||
...overlappingTourInfoWithCodriver,
|
||||
],
|
||||
)
|
||||
: undefined,
|
||||
];
|
||||
},
|
||||
)
|
||||
.flat()
|
||||
.filter((anomaly) => !!anomaly);
|
||||
const overlappingCarsWithDriver = toursInIntervalWithDriver.map(
|
||||
({ carName }) => carName,
|
||||
);
|
||||
const overlappingTourInfoWithDriver = toursInIntervalWithDriver.map(
|
||||
({ operationId, startDate }) => ({
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
operationId,
|
||||
}),
|
||||
);
|
||||
const overlappingCarsWithCodriver = toursInIntervalWithCodriver.map(
|
||||
({ carName }) => carName,
|
||||
);
|
||||
const overlappingTourInfoWithCodriver =
|
||||
toursInIntervalWithCodriver.map(({ operationId, startDate }) => ({
|
||||
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
|
||||
operationId,
|
||||
}));
|
||||
return [
|
||||
toursInIntervalWithDriver.length
|
||||
? this.createAnomaly(
|
||||
`${operationId}-${driverId}-${this.type}`,
|
||||
operationId,
|
||||
driverName,
|
||||
startDate,
|
||||
[carName, ...overlappingCarsWithDriver],
|
||||
[
|
||||
{
|
||||
operationId,
|
||||
startDate: format(
|
||||
new Date(startDate),
|
||||
'HH:mm dd.MM.yy',
|
||||
),
|
||||
},
|
||||
...overlappingTourInfoWithDriver,
|
||||
],
|
||||
)
|
||||
: undefined,
|
||||
toursInIntervalWithCodriver.length
|
||||
? this.createAnomaly(
|
||||
`${operationId}-${codriverId}-${this.type}`,
|
||||
operationId,
|
||||
codriverName,
|
||||
startDate,
|
||||
[carName, ...overlappingCarsWithCodriver],
|
||||
[
|
||||
{
|
||||
operationId,
|
||||
startDate: format(
|
||||
new Date(startDate),
|
||||
'HH:mm dd.MM.yy',
|
||||
),
|
||||
},
|
||||
...overlappingTourInfoWithCodriver,
|
||||
],
|
||||
)
|
||||
: undefined,
|
||||
];
|
||||
},
|
||||
)
|
||||
.flat()
|
||||
.filter((anomaly) => !!anomaly);
|
||||
result.push(intermediateStep);
|
||||
}
|
||||
return result.flat();
|
||||
}
|
||||
|
||||
async validate(anomaly: Anomaly): Promise<Anomaly> {
|
||||
|
|
|
|||
|
|
@ -179,8 +179,7 @@ export class CalculationService {
|
|||
): number {
|
||||
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
|
||||
// if TariffGroup is VDEK KTW, sum up costTypes
|
||||
const isVdek =
|
||||
tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 ?? false;
|
||||
const isVdek = tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1;
|
||||
|
||||
return tariff.costTypes.reduce((sum, costType) => {
|
||||
switch (costType.variant) {
|
||||
|
|
@ -281,12 +280,19 @@ export class CalculationService {
|
|||
|
||||
private calculateTSW(tour: Tour, costType: CostType): number {
|
||||
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
|
||||
) {
|
||||
return costType.costAmount;
|
||||
} else if (
|
||||
costType.description.includes('TSW 2') &&
|
||||
(costType.description.includes('TS 2') ||
|
||||
costType.description.includes('TSW 2')) &&
|
||||
tour.driverId !== tour.codriverId
|
||||
) {
|
||||
return costType.costAmount;
|
||||
|
|
@ -389,9 +395,16 @@ export class CalculationService {
|
|||
}
|
||||
const tenant = await this.prisma.avicennaTenant.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
contains: healthInsurance,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
name: healthInsurance,
|
||||
},
|
||||
{
|
||||
name: {
|
||||
contains: healthInsurance,
|
||||
},
|
||||
},
|
||||
],
|
||||
tariffGroups: {
|
||||
some: {
|
||||
ordinanceType,
|
||||
|
|
|
|||
|
|
@ -9,4 +9,4 @@ import { PersonnelCostCalculationService } from './personnel-cost-calculation.se
|
|||
providers: [CalculationService, PersonnelCostCalculationService],
|
||||
exports: [CalculationService],
|
||||
})
|
||||
export class UtilCalculationModule {}
|
||||
export class FeatureCalculationModule {}
|
||||
|
|
@ -19,4 +19,21 @@ export const INFECTION_TYPES = {
|
|||
Norovirus: '2.B',
|
||||
Sonstiges: '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,
|
||||
persId,
|
||||
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',
|
||||
TransformationDone = 'etl.transformation',
|
||||
LoadingDone = 'etl.loading',
|
||||
|
|
@ -7,7 +7,7 @@ import { Observable, combineLatest, from, map, switchMap, tap } from 'rxjs';
|
|||
import { ExtractorsService } from '../feat-extractors/extractors.service';
|
||||
import { LoadersService } from '../feat-loaders/loaders.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 { LoadingDoneEvent } from './events/loading-done.event';
|
||||
import { TransformationDoneEvent } from './events/transformation-done.event';
|
||||
|
|
@ -62,7 +62,7 @@ export class ImporterService {
|
|||
]).pipe(
|
||||
tap(() =>
|
||||
this.eventEmitter.emit(
|
||||
EventTypes.ExtractionDone,
|
||||
ETLEventTypes.ExtractionDone,
|
||||
new ExtractionDoneEvent(jobId),
|
||||
),
|
||||
),
|
||||
|
|
@ -84,7 +84,7 @@ export class ImporterService {
|
|||
]).pipe(
|
||||
tap(() =>
|
||||
this.eventEmitter.emit(
|
||||
EventTypes.TransformationDone,
|
||||
ETLEventTypes.TransformationDone,
|
||||
new TransformationDoneEvent(jobId),
|
||||
),
|
||||
),
|
||||
|
|
@ -104,7 +104,7 @@ export class ImporterService {
|
|||
switchMap(() => this.loadersService.loadWorkTimes()),
|
||||
tap(() =>
|
||||
this.eventEmitter.emit(
|
||||
EventTypes.LoadingDone,
|
||||
ETLEventTypes.LoadingDone,
|
||||
new LoadingDoneEvent(jobId),
|
||||
),
|
||||
),
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { Interval } from 'date-fns';
|
||||
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { chunk } from 'src/utils';
|
||||
|
||||
@Injectable()
|
||||
export class FinalRepository {
|
||||
|
|
@ -50,16 +51,21 @@ export class FinalRepository {
|
|||
|
||||
createTours(
|
||||
tourCreateInput: Prisma.TourCreateInput[],
|
||||
): Promise<Prisma.BatchPayload> {
|
||||
return this.prisma.tour.createMany({
|
||||
data: tourCreateInput,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
): Promise<Prisma.BatchPayload[]> {
|
||||
const chunks = chunk(tourCreateInput, 25000);
|
||||
return Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
this.prisma.tour.createMany({
|
||||
data: chunk,
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateTours(
|
||||
tourCreateInput: Prisma.TourCreateInput[],
|
||||
): Promise<Prisma.BatchPayload> {
|
||||
): Promise<Prisma.BatchPayload[]> {
|
||||
await this.prisma.tour.deleteMany({
|
||||
where: {
|
||||
operationId: {
|
||||
|
|
@ -70,17 +76,22 @@ export class FinalRepository {
|
|||
return this.createTours(tourCreateInput);
|
||||
}
|
||||
|
||||
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload> {
|
||||
return this.prisma.tour.updateMany({
|
||||
where: {
|
||||
operationId: {
|
||||
in: operationIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload[]> {
|
||||
const chunks = chunk(operationIds, 20000);
|
||||
return Promise.all(
|
||||
chunks.map((chunk) =>
|
||||
this.prisma.tour.updateMany({
|
||||
where: {
|
||||
operationId: {
|
||||
in: chunk,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
findPatients(): Promise<Patient[]> {
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
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 { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
|
||||
import { StagingRepository } from './staging.repository';
|
||||
import { TransformersService } from './transformers.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, DataAccessExtractorsModule, UtilCalculationModule],
|
||||
imports: [PrismaModule, DataAccessExtractorsModule, FeatureCalculationModule],
|
||||
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
|
||||
exports: [TransformersService],
|
||||
})
|
||||
|
|
@ -2,7 +2,7 @@ import { parse, parseISO } from 'date-fns';
|
|||
import { fromZonedTime } from 'date-fns-tz';
|
||||
|
||||
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 parseZonedDateTime =
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { PatientSource } from '@prisma/client';
|
||||
import { fakerDE as faker } from '@faker-js/faker';
|
||||
import { isValidDate } from 'src/utils';
|
||||
import { PatientPreStaging } from '../staging.interface';
|
||||
import {
|
||||
|
|
@ -11,6 +12,9 @@ export class PatientTransformer {
|
|||
public static transform(patientSource: PatientSource): PatientPreStaging {
|
||||
const patientParsed = {
|
||||
...patientSource,
|
||||
surname: faker.person.lastName(),
|
||||
name: faker.person.firstName(),
|
||||
street: faker.location.streetAddress(),
|
||||
...transformProperties(parseDateTime('yyyy-MM-dd'))(
|
||||
pick(['birthday'])(patientSource),
|
||||
),
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { PlannedTimeSource } from '@prisma/client';
|
||||
import { fakerDE as faker } from '@faker-js/faker';
|
||||
import { PlannedTimePreStaging } from '../staging.interface';
|
||||
import {
|
||||
parseBoolean,
|
||||
|
|
@ -14,6 +15,8 @@ export class PlannedTimeTransformer {
|
|||
): PlannedTimePreStaging {
|
||||
const plannedTimeParsed = {
|
||||
...plannedTimeSource,
|
||||
firstname: faker.person.firstName(),
|
||||
surname: faker.person.lastName(),
|
||||
...transformProperties(parseInteger)(
|
||||
pick(['id', 'user_id'])(plannedTimeSource),
|
||||
),
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { TourSource } from '@prisma/client';
|
||||
import { fakerDE as faker } from '@faker-js/faker';
|
||||
import { TourPreStaging } from '../staging.interface';
|
||||
import {
|
||||
parseBoolean,
|
||||
|
|
@ -15,6 +16,16 @@ export class TourTransformer {
|
|||
public static transform(tourSource: TourSource): TourPreStaging {
|
||||
const tourParsed = {
|
||||
...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(parseFloat)(
|
||||
pick(['besetztKm', 'gesamtPreis'])(tourSource),
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { WorkTimeSource } from '@prisma/client';
|
||||
import { fakerDE as faker } from '@faker-js/faker';
|
||||
import { WorkTimePreStaging } from '../staging.interface';
|
||||
import {
|
||||
parseCETDateTime,
|
||||
|
|
@ -11,6 +12,8 @@ export class WorkTimeTransformer {
|
|||
public static transform(workTimeSource: WorkTimeSource): WorkTimePreStaging {
|
||||
const workTimeParsed = {
|
||||
...workTimeSource,
|
||||
firstname: faker.person.firstName(),
|
||||
surname: faker.person.lastName(),
|
||||
...transformProperties(parseInteger)(
|
||||
pick(['id', 'user_id'])(workTimeSource),
|
||||
),
|
||||
|
|
@ -22,22 +22,27 @@ export class TicketLifecycleService {
|
|||
constructor(private readonly repository: TicketRepository) {}
|
||||
|
||||
async createTickets(interval: Interval): Promise<void> {
|
||||
const tours = await this.repository.findTours(interval);
|
||||
try {
|
||||
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]);
|
||||
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();
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
}
|
||||
|
||||
await this.validateCreatedTickets();
|
||||
// await this.archiveClosedTickets();
|
||||
}
|
||||
|
||||
async validateDocumentInfoUpdate(
|
||||
|
|
@ -162,11 +167,11 @@ export class TicketLifecycleService {
|
|||
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 {
|
||||
const patient = await this.repository.findPatientById(patientId);
|
||||
if (!patient) {
|
||||
patientResult = validatePatientDataOnTours(ticket.tours);
|
||||
} else {
|
||||
patientResult = validatePatient(patient);
|
||||
}
|
||||
}
|
||||
const tourResults = ticket.tours.map((tour) => validateTour(tour));
|
||||
|
|
|
|||
|
|
@ -30,35 +30,40 @@ export class TicketRepository {
|
|||
) {}
|
||||
|
||||
async createTicket(tours: Tour[]) {
|
||||
const ticket = await this.db
|
||||
.insertInto('Ticket')
|
||||
.values({
|
||||
currentState: TicketValidationState.CREATED,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
this.logger.log(`Creating Ticket for ${tours.length} tours`);
|
||||
try {
|
||||
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
|
||||
.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();
|
||||
await this.db
|
||||
.updateTable('Tour')
|
||||
.set({
|
||||
ticketId: ticket.id,
|
||||
})
|
||||
.where(
|
||||
'id',
|
||||
'in',
|
||||
tours.map(({ id }) => id),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return ticket;
|
||||
return ticket;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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