init data-connector

This commit is contained in:
Marcel Arndt 2025-04-10 17:32:06 +02:00
parent 52c1a14609
commit 46631db5b2
105 changed files with 8081 additions and 338 deletions

View File

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

View File

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

View File

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

View File

@ -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": [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { ObjectType, Field } from '@nestjs/graphql';
@ObjectType('Interval')
export class IntervalObjectType {
@Field()
start: Date;
@Field()
end: Date;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export enum AccountingState {
Billable = 'Billable',
PreparedForBilling = 'PreparedForBilling',
Billed = 'Billed',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,71 @@
import { ApprovalState, Prisma } from '@prisma/client';
export interface TicketFilter {
errors?: string[];
approvalStates?: ApprovalState[];
documentInfo?: ('approval' | 'transport_document')[];
}
export function createPrismaQueryFromTicketFilters(
filter: TicketFilter,
): Prisma.TicketWhereInput[] {
const conditions: Prisma.TicketWhereInput[] = [];
Object.entries(filter ?? {}).forEach(([property, args]) => {
switch (property) {
case 'errors':
if (args.length > 0)
conditions.push({
errors: {
hasSome: [...args],
},
});
return;
case 'approvalStates':
if (args.length > 0)
conditions.push({
approvalState: {
in: [...args],
},
});
return;
case 'documentInfo':
if (args.includes('approval')) {
conditions.push({
OR: [
{
documentInfo: {
path: ['approval', 'location'],
not: 'company',
},
},
{
documentInfo: {
path: ['approval', 'location'],
equals: Prisma.JsonNull,
},
},
],
});
}
if (args.includes('td_company')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'company',
},
});
}
if (args.includes('td_customer')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'customer',
},
});
}
return;
}
});
return conditions;
}

View File

@ -0,0 +1,240 @@
import { Injectable, Logger } from '@nestjs/common';
import {
ApprovalState,
Ticket,
TicketValidationState,
Tour,
} from '@prisma/client';
import { 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;
}
}

View File

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

View File

@ -0,0 +1,153 @@
import { Patient, Tour } from '@prisma/client';
export interface ValidationResult {
result: boolean;
notes: string[];
errors: string[];
}
export function validatePatient(patient: Patient): ValidationResult {
const notes: string[] = [];
const errors: string[] = [];
let isValid = true;
if (!patient?.name) {
isValid = false;
errors.push('Patient: Vorname ist nicht gefüllt');
}
if (!patient?.surname) {
isValid = false;
errors.push('Patient: Nachname ist nicht gefüllt');
}
if (!patient?.birthday) {
isValid = false;
notes.push('Patient: Geburtstag ist nicht gefüllt');
}
if (!patient?.healthinsurance) {
isValid = false;
errors.push('Patient: Krankenkasse ist nicht gefüllt');
}
if (!patient?.careDegree) {
notes.push('Patient: Pflegegrad ist nicht gefüllt');
}
if (!patient?.disabilityMark1) {
notes.push('Patient: Merkzeichen 1 ist nicht gefüllt');
}
if (!patient?.disabilityMark2) {
notes.push('Patient: Merkzeichen 2 ist nicht gefüllt');
}
return {
result: isValid,
errors,
notes,
};
}
export function validatePatientDataOnTours(tours: Tour[]): ValidationResult {
const errors: string[] = [];
let isValid = true;
for (const tour of tours) {
if (!tour.patientName) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Vorname ist nicht gefüllt`,
);
}
if (!tour.patientSurname) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Nachname ist nicht gefüllt`,
);
}
if (!tour.healthInsurance) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Krankenversicherung ist nicht gefüllt`,
);
}
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTour(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.ordinanceType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Verordnungsart ist nicht gefüllt`);
}
if (!tour.transportType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Transportart ist nicht gefüllt`);
}
if (!tour.category) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Kategorie ist nicht gefüllt`);
}
if (!tour.startDate) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Startzeit ist nicht gefüllt`);
}
if (!tour.startStreet || !tour.startZip || !tour.startCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Start Adresse ist nicht gefüllt`);
}
if (!tour.targetStreet || !tour.targetZip || !tour.targetCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Ziel Adresse ist nicht gefüllt`);
}
if (tour.occupiedKm > 9990) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Besetzte Kilometer sind zu hoch`);
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTransport(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.carName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrzeugname ist nicht angegeben`);
}
if (!tour.driverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrer ist nicht angegeben`);
}
if (!tour.codriverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Beifahrer ist nicht angegeben`);
}
if (!tour.startBegin) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Anfahrt-Stempel fehlt`);
}
if (!tour.empty) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Frei-Stempel fehlt`);
}
return {
result: isValid,
errors,
notes: [],
};
}

View File

@ -0,0 +1,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',
});

View File

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

View File

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

View File

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

View File

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

View File

@ -105,6 +105,9 @@ export class AnomalyDetectorService {
},
],
},
orderBy: {
startDate: 'asc',
},
});
}

View File

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

View File

@ -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,10 +20,25 @@ 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
const toursMap = this.groupToursByDay(toursInRange);
const result = [];
for (const [, tours] of Object.entries(toursMap)) {
const intermediateStep = tours
.map(
(
{
@ -62,11 +77,12 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
const tourInterval = this.getTourInterval(start, end, startDate);
const toursInInterval = tours
.filter((tour) =>
isWithinInterval(tour.startDate, {
start: startOfDay(startDate),
end: endOfDay(startDate),
}),
.filter(
(tour) => isSameDay(tour.startDate, startDate),
// isWithinInterval(tour.startDate, {
// start: startOfDay(startDate),
// end: endOfDay(startDate),
// }),
)
.filter(
({
@ -127,11 +143,18 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
);
const toursInIntervalWithDriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
driverId === tourListDriverId || driverId === tourListCodriverId,
({
driverId: tourListDriverId,
codriverId: tourListCodriverId,
}) =>
driverId === tourListDriverId ||
driverId === tourListCodriverId,
);
const toursInIntervalWithCodriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
({
driverId: tourListDriverId,
codriverId: tourListCodriverId,
}) =>
codriverId === tourListDriverId ||
codriverId === tourListCodriverId,
);
@ -164,7 +187,10 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
startDate: format(
new Date(startDate),
'HH:mm dd.MM.yy',
),
},
...overlappingTourInfoWithDriver,
],
@ -180,7 +206,10 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
startDate: format(
new Date(startDate),
'HH:mm dd.MM.yy',
),
},
...overlappingTourInfoWithCodriver,
],
@ -191,6 +220,9 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
)
.flat()
.filter((anomaly) => !!anomaly);
result.push(intermediateStep);
}
return result.flat();
}
async validate(anomaly: Anomaly): Promise<Anomaly> {

View File

@ -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: {
OR: [
{
name: healthInsurance,
},
{
name: {
contains: healthInsurance,
},
},
],
tariffGroups: {
some: {
ordinanceType,

View File

@ -9,4 +9,4 @@ import { PersonnelCostCalculationService } from './personnel-cost-calculation.se
providers: [CalculationService, PersonnelCostCalculationService],
exports: [CalculationService],
})
export class UtilCalculationModule {}
export class FeatureCalculationModule {}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export enum EventTypes {
export enum ETLEventTypes {
ExtractionDone = 'etl.extraction',
TransformationDone = 'etl.transformation',
LoadingDone = 'etl.loading',

View File

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

View File

@ -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,
): 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({
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload[]> {
const chunks = chunk(operationIds, 20000);
return Promise.all(
chunks.map((chunk) =>
this.prisma.tour.updateMany({
where: {
operationId: {
in: operationIds,
in: chunk,
},
},
data: {
deletedAt: new Date(),
},
});
}),
),
);
}
findPatients(): Promise<Patient[]> {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,12 +22,14 @@ export class TicketLifecycleService {
constructor(private readonly repository: TicketRepository) {}
async createTickets(interval: Interval): Promise<void> {
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]);
@ -38,6 +40,9 @@ export class TicketLifecycleService {
await this.validateCreatedTickets();
// await this.archiveClosedTickets();
} catch (error) {
this.logger.error(error);
}
}
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 {
if (!patient) {
patientResult = validatePatientDataOnTours(ticket.tours);
} else {
patientResult = validatePatient(patient);
}
}
const tourResults = ticket.tours.map((tour) => validateTour(tour));

View File

@ -30,6 +30,8 @@ export class TicketRepository {
) {}
async createTicket(tours: Tour[]) {
this.logger.log(`Creating Ticket for ${tours.length} tours`);
try {
const ticket = await this.db
.insertInto('Ticket')
.values({
@ -59,6 +61,9 @@ export class TicketRepository {
.execute();
return ticket;
} catch (error) {
throw error;
}
}
async addToursToTicket(ticketId: string, tours: Tour[]) {

View File

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