modernize ticket and accounting

iteration 0
This commit is contained in:
Marcel Arndt
2025-11-24 21:31:43 +01:00
parent c33ab89354
commit b1a70673a4
97 changed files with 13619 additions and 2398 deletions
@@ -3,6 +3,9 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
"**/*.hbs"
]
}
}
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -12,6 +12,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"prisma": "prisma",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@@ -25,6 +26,7 @@
"dependencies": {
"@apollo/server": "^4.12.0",
"@faker-js/faker": "^9.6.0",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^13.0.4",
"@nestjs/axios": "^4.0.0",
"@nestjs/bullmq": "^11.0.2",
@@ -43,14 +45,18 @@
"bullmq": "^5.56.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"form-data": "^4.0.4",
"graphql": "^16.10.0",
"graphql-subscriptions": "^3.0.0",
"graphql-ws": "^6.0.4",
"handlebars": "^4.7.8",
"kysely": "^0.27.6",
"nodemailer": "^7.0.10",
"pg": "^8.14.1",
"prisma-kysely": "^1.8.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"uuid": "^8.3.2"
},
"devDependencies": {
"@nestjs/cli": "^11.0.6",
@@ -58,7 +64,9 @@
"@nestjs/testing": "^11.0.15",
"@types/express": "^5.0.1",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^22.14.0",
"@types/nodemailer": "^7.0.3",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
@@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "TourFile" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"tourId" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"storagePath" TEXT NOT NULL,
"originalFilename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSizeBytes" INTEGER NOT NULL,
"dlUploadStatus" TEXT NOT NULL DEFAULT 'pending',
"createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TourFile_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TourFile_tourId_ticketId_idx" ON "TourFile"("tourId", "ticketId");
@@ -421,6 +421,23 @@ model Tour {
ticketId String? @db.Uuid
}
model TourFile {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
tourId String
ticketId String
storagePath String
originalFilename String
mimeType String
fileSizeBytes Int
dlUploadStatus String @default("pending")
createdAt DateTime? @default(now())
@@index([tourId, ticketId])
}
model Patient {
id String @id
surname String?
@@ -1,7 +1,6 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { HttpModule } from '@nestjs/axios';
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { Logger, MiddlewareConsumer, Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { GraphQLModule } from '@nestjs/graphql';
import { ScheduleModule } from '@nestjs/schedule';
@@ -11,8 +10,11 @@ import { Pool } from 'pg';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './core/config/config.module';
import { ConfigService } from './core/config/config.service';
import { DatabaseModule } from './core/database/database.module';
import { PrismaModule } from './core/database/prisma.module';
import { MailModule } from './core/mail/mail.module';
import { RequestLoggerMiddleware } from './core/middleware/request-logger.middleware';
import { ApplicationAccountingModule } from './modules/app-accounting/app-accounting.module';
import { ApplicationControlCenterModule } from './modules/app-control-center/app-control-center.module';
import { AppHomeModule } from './modules/app-home/app-home.module';
@@ -23,59 +25,36 @@ import { FeatCalculationModule } from './modules/feat-calculation/feat-calculati
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
import { FeatBusinessObjectsModule } from './modules/feat-business-objects/feat-business-objects.module';
import { OtelMetricsService } from './core/service/otel-metrics.service';
@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',
port: 5432,
database: 'avicenna',
user: 'johndoe',
password: 'randompassword',
DatabaseModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
dialect: new PostgresDialect({
pool: new Pool({
host: config.databaseHost(),
port: config.databasePort(),
database: config.databaseName(),
user: config.databaseUser(),
password: config.databasePassword(),
}),
}),
}),
inject: [ConfigService],
}),
BullModule.forRoot({
connection: {
host: 'localhost',
port: 6379,
},
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
connection: {
host: config.redisHost(),
port: config.redisPort(),
},
}),
inject: [ConfigService],
}),
// DatabaseModule.forRoot({
// dialect: new PostgresDialect({
// pool: new Pool({
// host: 'localhost',
// port: 54322,
// database: 'postgres',
// user: 'postgres',
// password: 'postgres',
// }),
// }),
// }),
PrismaModule.forRoot(),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -88,6 +67,7 @@ import { FeatBusinessObjectsModule } from './modules/feat-business-objects/feat-
'graphql-ws': true,
},
}),
MailModule,
FeatureEtlModule,
FeatReportsModule,
FeatCalculationModule,
@@ -100,6 +80,10 @@ import { FeatBusinessObjectsModule } from './modules/feat-business-objects/feat-
AppHomeModule,
],
controllers: [AppController],
providers: [AppService],
providers: [AppService, Logger, OtelMetricsService],
})
export class AppModule {}
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggerMiddleware).forRoutes('*');
}
}
@@ -39,8 +39,8 @@ export class ConfigService extends NestConfigService {
dyflexisAppPassword(): string {
return this.get('DYFLEXIS_APP_PASSWORD');
}
databasePort(): string {
return this.get('DATABASE_PORT');
databasePort(): number {
return +this.get('DATABASE_PORT');
}
databaseHost(): string {
return this.get('DATABASE_HOST');
@@ -54,4 +54,28 @@ export class ConfigService extends NestConfigService {
databaseName(): string {
return this.get('DATABASE_DATABASE');
}
redisPort(): number {
return +this.get('REDIS_PORT');
}
redisHost(): string {
return this.get('REDIS_HOST');
}
storagePath(): string {
return this.get('STORAGE_PATH');
}
mailHost(): string {
return this.get('SMTP_HOST');
}
mailPort(): string {
return this.get('SMTP_PORT');
}
mailUser(): string {
return this.get('SMTP_USER');
}
mailPass(): string {
return this.get('SMTP_PASS');
}
mailFrom(): string {
return this.get('MAIL_FROM');
}
}
@@ -7,7 +7,7 @@ import {
Type,
} from '@nestjs/common';
import { KyselyConfig } from 'kysely';
import { createKyselyProvider } from './kysely-client.factory';
import { createKyselyClient, createKyselyProvider } from './kysely-client.factory';
export const KYSELY_CLIENT_PROVIDER_TOKEN = 'KyselyClientProviderToken';
export const KYSELY_CONFIG_TOKEN = 'KYSELY_CONFIG_TOKEN';
@@ -21,11 +21,10 @@ export interface KyselyModuleAsyncOptions
isGlobal?: boolean;
useExisting?: Type<KyselyConfigFactory>;
useClass?: Type<KyselyConfigFactory>;
// FIX 1: Erlaube Promise im Rückgabetyp
useFactory?: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: any[]
) => KyselyConfig;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<KyselyConfig> | KyselyConfig;
inject?: any[];
}
@@ -43,11 +42,22 @@ export class DatabaseModule {
}
public static forRootAsync(options: KyselyModuleAsyncOptions): DynamicModule {
// Wir erstellen den Client-Provider, der auf den Config-Provider wartet
const clientProvider: Provider = {
provide: KYSELY_CLIENT_PROVIDER_TOKEN,
useFactory: (config: KyselyConfig) => createKyselyClient(config),
inject: [KYSELY_CONFIG_TOKEN], // Injiziert das Ergebnis von createAsyncOptionsProvider
};
return {
global: options.isGlobal,
module: DatabaseModule,
imports: options.imports || [],
providers: this.createAsyncProviders(options),
providers: [
...this.createAsyncProviders(options),
clientProvider,
],
exports: [clientProvider],
};
}
@@ -55,12 +65,12 @@ export class DatabaseModule {
options: KyselyModuleAsyncOptions,
): Provider[] {
if (options.useExisting || options.useFactory) {
return this.createAsyncOptionsProvider(options);
return [this.createAsyncOptionsProvider(options)];
}
if (options.useClass) {
return [
...this.createAsyncOptionsProvider(options),
this.createAsyncOptionsProvider(options),
{
provide: options.useClass,
useClass: options.useClass,
@@ -68,56 +78,28 @@ export class DatabaseModule {
];
}
return [...this.createAsyncOptionsProvider(options)];
throw new Error('Invalid configuration for DatabaseModule: One of useClass, useExisting, or useFactory must be provided.');
}
// Dieser Provider stellt NUR die Konfiguration (KyselyConfig) bereit
private static createAsyncOptionsProvider(
options: KyselyModuleAsyncOptions,
): Provider[] {
): 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 [
{
return {
provide: KYSELY_CONFIG_TOKEN,
useFactory: async (optionsFactory: KyselyConfigFactory) =>
await optionsFactory.createKyselyConfig(),
inject: [],
},
];
useFactory: options.useFactory,
inject: options.inject || [],
};
}
const inject = [options.useClass || options.useExisting];
return {
provide: KYSELY_CONFIG_TOKEN,
useFactory: async (optionsFactory: KyselyConfigFactory) =>
await optionsFactory.createKyselyConfig(),
inject: inject,
};
}
}
}
@@ -10,6 +10,7 @@ input AccountingFilterArgs {
enum AccountingState {
Billable
Billed
Pending
PreparedForBilling
}
@@ -138,6 +139,7 @@ type Metric {
type Mutation {
acceptAnomaly(id: String!): Anomaly!
sendApprovalRequest(email: String!, ticketId: String!): Boolean!
setAnomalyToSleep(id: String!): Anomaly!
setDocumentInfo(approval: ApprovalInfoArgs, tdLocation: String, ticketId: String!): Ticket!
}
@@ -0,0 +1,39 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { Module } from '@nestjs/common';
import { join } from 'path';
import { ConfigService } from '../config/config.service';
import { MailService } from './mail.service';
@Module({
imports: [
MailerModule.forRootAsync({
useFactory: (config: ConfigService) => ({
transport: {
host: config.mailHost(),
port: +config.mailPort(),
secure: false,
auth: {
user: config.mailUser(),
pass: config.mailPass(),
},
pool: true,
},
defaults: {
from: config.mailFrom(),
},
template: {
dir: join(process.cwd(), 'dist/core/mail/templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
inject: [ConfigService],
}),
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
@@ -0,0 +1,35 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, Logger } from '@nestjs/common';
import { TourFile } from '@prisma/client';
import { ConfigService } from '../config/config.service';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private readonly mailerService: MailerService, private readonly config: ConfigService) {}
async sendGenehmigungsanfrage(recipient: string, transportschein: TourFile) {
this.logger.log(
`sending Genehmigungsanfrage mail to ${recipient} with Transportschein ${transportschein.originalFilename} for Tour ${transportschein.tourId}`,
);
try {
await this.mailerService.sendMail({
to: recipient,
bcc: 'info@avicenna.hamburg',
subject: `Antrag auf Kostenübernahme`,
template: 'genehmigungsanfrage',
attachments: [
{
filename: transportschein.originalFilename,
path: transportschein.storagePath,
contentType: transportschein.mimeType,
},
],
});
} catch (error) {
this.logger.error('failed to send mail', error)
}
}
}
@@ -0,0 +1 @@
export const MAILING_QUEUE = 'mailing_queue';
@@ -0,0 +1,18 @@
<div style="font-family: sans-serif; white-space: pre-wrap;">
Sehr geehrte Damen und Herren,
für den betreffenden Transport wurde bereits eine Kostenübernahme gemäß § 6 Abs. 3 der Krankentransport-Richtlinie beantragt. Leider liegt uns bislang keine Genehmigung vor.
Da uns ohne Genehmigung keine Abrechnung mit der Krankenkasse möglich ist, verzögert sich der gesamte Abrechnungsprozess. Um eine private Rechnungsstellung an den Patienten zu vermeiden, bitten wir Sie um zeitnahe Übermittlung der Genehmigung.
Bitte senden Sie die Unterlagen an eine der folgenden Stellen:
• E-Mail: info@avicenna.hamburg
• Fax: 040 460006820
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg
• Institutionskennzeichen: 600224841
Für Rückfragen stehen wir Ihnen selbstverständlich gerne zur Verfügung.
Vielen Dank vorab.
Mit freundlichen Grüßen
Ihr Team von Avicenna
</div>
@@ -0,0 +1,15 @@
<div style="font-family: sans-serif; white-space: pre-wrap;">
Sehr geehrte Damen und Herren,<br>
<br>
hiermit beantragen wir im Auftrag unseres Versicherten die Fahrtkostenerstattung gemäß § 6 Abs. 3 der Krankentransport-Richtlinie.<br>
<br>
Die notwendigen Details entnehmen Sie bitte der beigefügten ärztlichen Verordnung (PDF-Datei).<br>
<br>
Bitte senden Sie uns die Genehmigung an:<br>
• E-Mail: info@avicenna.hamburg<br>
• Fax: 040 460006820<br>
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg<br>
• Institutionskennzeichen: 600224841<br>
<br>
Ihr Team der Avicenna Ambulance<br>
</div>
@@ -0,0 +1,20 @@
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
import { ConfigService } from '../config/config.service';
import { diskStorage } from 'multer';
import * as path from 'path';
import { v4 as uuidv4 } from 'uuid';
export function createMulterOptions(config: ConfigService): MulterOptions {
const storagePath = config.storagePath();
return {
storage: diskStorage({
destination: (req, file, cb) => cb(null, storagePath),
filename: (req, file, cb) => {
const extension = path.extname(file.originalname);
const filename = `${uuidv4()}${extension}`;
cb(null, filename);
},
}),
};
}
@@ -1,4 +1,5 @@
export enum AccountingState {
Pending = 'Pending',
Billable = 'Billable',
PreparedForBilling = 'PreparedForBilling',
Billed = 'Billed',
@@ -5,10 +5,34 @@ import { FeatTicketSystemModule } from '../feat-tickets/ticket-system.module';
import { TicketRepository } from './ticket.repository';
import { TicketsResolver } from './tickets.resolver';
import { TicketSystemService } from './ticket-system.service';
import { TicketsController } from './tickets.controller';
import { MulterModule } from '@nestjs/platform-express';
import { ConfigModule } from 'src/core/config/config.module';
import { ConfigService } from 'src/core/config/config.service';
import { createMulterOptions } from 'src/core/storage/multer.config';
import { BullModule } from '@nestjs/bullmq';
import { TOUR_FILE_UPLOAD_QUEUE } from '../feat-business-objects/entities/tour/tour.tokens';
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
@Module({
imports: [PrismaModule, FeatTicketSystemModule, FeatBusinessObjectsModule],
imports: [
PrismaModule,
FeatTicketSystemModule,
FeatBusinessObjectsModule,
MulterModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => createMulterOptions(config),
inject: [ConfigService],
}),
BullModule.registerQueue({
name: TOUR_FILE_UPLOAD_QUEUE,
}),
BullModule.registerQueue({
name: MAILING_QUEUE,
}),
],
providers: [TicketRepository, TicketSystemService, TicketsResolver],
controllers: [TicketsController],
exports: [TicketSystemService],
})
export class ApplicationTicketSystemModule {}
@@ -1,18 +1,21 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
import { Queue } from 'bullmq';
import { PubSub } from 'graphql-subscriptions';
import {
decodeOffsetCursor,
encodeCursor,
} from 'src/core/base/pagination.util';
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { TicketLifecycleService } from '../feat-tickets/ticket-lifecycle.service';
import {
TicketSystemEventTypes,
TicketSystemValidatedEvent,
} from '../feat-tickets/ticket-system.events';
} from '../feat-business-objects/entities/ticket/ticket-system.events';
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
import {
PaginatedTicketObjectType,
@@ -20,6 +23,7 @@ import {
TicketStateMetaObjectType,
} from './ticket.object-type';
import { TicketRepository } from './ticket.repository';
import { TourFilesService } from '../feat-business-objects/entities/tour-files/tour-files.service';
@Injectable()
export class TicketSystemService {
@@ -36,13 +40,33 @@ export class TicketSystemService {
private readonly repository: TicketRepository,
private readonly ticketService: TicketService,
private readonly tourService: TourService,
private readonly tourFileService: TourFilesService,
private readonly lifecycle: TicketLifecycleService,
@InjectQueue(MAILING_QUEUE) private readonly approvalQueue: Queue,
) {}
async updateDocumentInfo(ticketId: string, documentInfo: DocumentInfoArgs) {
return this.lifecycle.validateDocumentInfoUpdate(ticketId, documentInfo);
}
async addApprovalRequestJob(ticketId: string, recipientEmail: string): Promise<boolean> {
const attachment = await this.tourFileService.findByTicketId(ticketId)
if (!attachment) {
throw new NotFoundException(
`Kein Dokument für Ticket ${ticketId} gefunden. Bitte erst hochladen.`,
);
}
await this.approvalQueue.add('approval_request', {
ticketId,
tourFileId: attachment.id,
recipientEmail,
});
return true;
}
findById(id: string): Promise<Ticket> {
return this.ticketService.findById(id);
}
@@ -0,0 +1,62 @@
import { InjectQueue } from '@nestjs/bullmq';
import {
Controller,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Queue } from 'bullmq';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { TOUR_FILE_UPLOAD_QUEUE } from '../feat-business-objects/entities/tour/tour.tokens';
import { TourFilesService } from '../feat-business-objects/entities/tour-files/tour-files.service';
@Controller('api/tickets')
export class TicketsController {
private readonly logger = new Logger(TicketsController.name);
constructor(
private readonly tourService: TourService,
private readonly tourFilesService: TourFilesService,
@InjectQueue(TOUR_FILE_UPLOAD_QUEUE)
private readonly transportscheinQueue: Queue,
) {}
@Post(':ticketId/files')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
async uploadFile(
@Param('ticketId') ticketId: string,
@UploadedFile() file: Express.Multer.File,
) {
this.logger.log(
`file received for ticket ${ticketId ?? ''}: ${file.filename}`,
);
const existingFile = await this.tourFilesService.findByTicketId(ticketId);
if (existingFile) {
return {message: 'File already exists!'}
}
const storage = file.path;
const tours = await this.tourService.findByTicketId(ticketId);
for (const tour of tours) {
const tourFile = await this.tourFilesService.createTourFile({
ticketId,
tourId: tour.id,
storagePath: storage,
file: file,
});
await this.transportscheinQueue.add('Transportschein', {
tourFileId: tourFile.id,
});
}
return { message: 'File accepted and processing started' };
}
}
@@ -61,6 +61,14 @@ export class TicketsResolver {
return this.ticketsService.getTicketStateKeyFigures(ticketState);
}
@Mutation(() => Boolean)
sendApprovalRequest(
@Args('ticketId', {type: () => String}) ticketId: string,
@Args('email', {type: () => String}) email: string,
) {
return this.ticketsService.addApprovalRequestJob(ticketId, email)
}
@Mutation(() => TicketObjectType)
setDocumentInfo(
@Args('ticketId', { type: () => String }) ticketId: string,
@@ -19,7 +19,6 @@ export class EmployeeStillWorkingDetector extends AnomalyDetector {
const longWorkingEmployees =
await this.dataService.findEmployeeStillWorking(day);
console.log(longWorkingEmployees)
if (longWorkingEmployees.length > 0) {
anomalies.push(
this.createAnomaly(
@@ -17,7 +17,6 @@ export class IsWorkTimePlannedDetector extends AnomalyDetector {
for (const day of eachDayOfInterval(inInterval)){
const workTimeWithoutPlan = await this.dataService.findWorkForPlannedTime(day)
console.log(day, workTimeWithoutPlan)
}
return
@@ -44,11 +44,7 @@ export class TicketRepository {
async findAllCreated(): Promise<(Ticket & { tours: Tour[] })[]> {
return await this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.USER_INTERACTION_REQUIRED,
},
},
currentState: TicketValidationState.CREATED,
},
include: {
tours: true,
@@ -185,7 +181,9 @@ export class TicketRepository {
}
async createTicket(tours: Tour[]): Promise<Ticket> {
this.logger.log(`Creating Ticket for ${tours.length} tours`);
this.logger.log(
`Creating Ticket for tours: ${tours.map(({ operationId }) => operationId).join(', ')}`,
);
try {
return await this.db.transaction().execute(async (trx) => {
const ticket = await trx
@@ -224,6 +222,9 @@ export class TicketRepository {
}
async addToursToTicket(ticketId: string, tours: Tour[]): Promise<Ticket> {
this.logger.log(
`Adding tours to ticket: ${tours.map(({ operationId }) => operationId).join(', ')}`,
);
await this.db
.updateTable('Tour')
.set({
@@ -288,6 +289,9 @@ export class TicketRepository {
approvalPath?: string;
},
): Promise<Ticket & { tours: Tour[] }> {
this.logger.log(
`updating ticket (${ticketId}) with updateset ${JSON.stringify(data)}`,
);
const { state, ...ticketData } = data;
await this.db
@@ -1,20 +1,22 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import {
ApprovalState,
Ticket,
TicketStateHistory,
TicketValidationState,
Tour,
Tour
} from '@prisma/client';
import {
TicketFilterDto,
UpdateDocumentInfoDto,
} from 'src/modules/feat-business-objects/entities/ticket/tickets.dto';
import { TicketRepository } from './ticket.repository';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { TicketSystemEventTypes, TicketSystemValidatedEvent } from './ticket-system.events';
@Injectable()
export class TicketService {
constructor(private readonly repository: TicketRepository) {}
constructor(private readonly repository: TicketRepository, private readonly eventEmitter: EventEmitter2) {}
async findById(ticketId: string): Promise<Ticket> {
return this.repository.findById(ticketId);
@@ -78,6 +80,24 @@ export class TicketService {
return this.repository.setStateOfTicket(ticketId, state);
}
async approvalRequestedForTicket(ticketId: string) {
return this.repository.updateTicket(ticketId, {
state: TicketValidationState.DOCUMENTS_MISSING,
approvalState: ApprovalState.REQUESTED,
documentInfo: {
tdLocation: 'unknown',
approval: {
whoRequested: 'Avicenna',
answeredFrom: 'System',
isNeeded: true,
},
},
}).then(() => this.eventEmitter.emit(
TicketSystemEventTypes.Validated,
new TicketSystemValidatedEvent(0)
));
}
async updateTicket(
ticketId: string,
data: {
@@ -0,0 +1,6 @@
export interface TourFileDto {
tourId: string;
ticketId: string;
storagePath: string;
file: Express.Multer.File
}
@@ -0,0 +1,60 @@
import { Inject, Injectable } from '@nestjs/common';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { TourFileDto } from './tour-files.dto';
import { Kysely, UpdateResult } from 'kysely';
import { TourFile } from '@prisma/client';
@Injectable()
export class TourFilesRepository {
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
createTourFile(tourFileDto: TourFileDto): Promise<TourFile> {
return this.db
.insertInto('TourFile')
.values({
tourId: tourFileDto.tourId,
ticketId: tourFileDto.ticketId,
storagePath: tourFileDto.storagePath,
originalFilename: tourFileDto.file.originalname,
mimeType: tourFileDto.file.mimetype,
fileSizeBytes: tourFileDto.file.size,
dlUploadStatus: 'pending',
})
.returningAll()
.executeTakeFirst();
}
findById(id: string): Promise<TourFile> {
return this.db
.selectFrom('TourFile')
.selectAll()
.where('TourFile.id', '=', id)
.executeTakeFirst();
}
findByTicketId(ticketId: string): Promise<TourFile> {
return this.db
.selectFrom('TourFile')
.selectAll()
.where('TourFile.ticketId', '=', ticketId)
.executeTakeFirst();
}
findByTourId(tourId: string): Promise<TourFile> {
return this.db
.selectFrom('TourFile')
.selectAll()
.where('TourFile.tourId', '=', tourId)
.executeTakeFirst();
}
updateStatus(id: string, newStatus: string): Promise<UpdateResult> {
return this.db
.updateTable('TourFile')
.where('id', '=', id)
.set('dlUploadStatus', newStatus)
.executeTakeFirst();
}
}
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { TourFileDto } from './tour-files.dto';
import { TourFilesRepository } from './tour-files.repository';
import { TourFile } from '@prisma/client';
@Injectable()
export class TourFilesService {
constructor(private readonly repository: TourFilesRepository) {}
createTourFile(tourFileDto: TourFileDto): Promise<TourFile> {
return this.repository.createTourFile(tourFileDto);
}
findById(tourFileId: string): Promise<TourFile> {
return this.repository.findById(tourFileId);
}
findByTicketId(ticketId: string): Promise<TourFile> {
return this.repository.findByTicketId(ticketId);
}
findByTourId(tourId: string): Promise<TourFile> {
return this.repository.findByTourId(tourId);
}
updateStatus(id: string, newStatus: 'pending' | 'failed' | 'success') {
return this.repository.updateStatus(id, newStatus);
}
}
@@ -1,16 +1,13 @@
import { Inject, Injectable } from '@nestjs/common';
import { Prisma, Tour } from '@prisma/client';
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns';
import { ExpressionBuilder, Kysely } from 'kysely';
import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
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 { AccountingState } from 'src/modules/app-accounting/accounting-state';
import { chunk } from 'src/utils';
import {
createPrismaQueryFromAccountingFilters,
TourAccountingFilterDto,
} from './tours.filter';
import { TourAccountingFilterDto } from './tours.filter';
type TourEb = ExpressionBuilder<DB, 'Tour'>;
@@ -120,35 +117,27 @@ export class TourRepository {
interval: Interval,
ordinanceTypes: string[] = [],
): Promise<Tour[]> {
return await this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
{
OR: [
...(ordinanceTypes.length
? ordinanceTypes.map((ot) => ({
ordinanceType: { contains: ot },
}))
: []),
],
},
...onlyRealTours(),
],
},
orderBy: {
startDate: 'desc',
},
});
return await this.db
.selectFrom('Tour')
.selectAll()
.where((eb) =>
eb.and([
eb('Tour.startDate', '>=', new Date(interval.start)),
eb('Tour.startDate', '<', new Date(interval.end)),
...(ordinanceTypes.length > 0
? [
eb.or(
ordinanceTypes.map((ot) =>
eb('Tour.ordinanceType', 'like', `%${ot}%`),
),
),
]
: []),
onlyRealToursKysely(eb),
]),
)
.orderBy('Tour.startDate asc')
.execute();
}
async findUpdatedAtBetween(
@@ -289,60 +278,45 @@ export class TourRepository {
async findFirstRecurring(tour: Tour): Promise<Tour | null> {
const institutionStreet =
tour.direction === 'Hinfahrt' ? tour.targetStreet : tour.startStreet;
return await 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',
},
});
return await this.db
.selectFrom('Tour')
.selectAll()
.where('type', '=', 'Serie')
.where('category', '=', tour.category)
.where('ordinanceType', '=', tour.ordinanceType)
.where('patientId', '=', tour.patientId)
.where('patientName', '=', tour.patientName)
.where('patientSurname', '=', tour.patientSurname)
.where('patientStreet', '=', tour.patientStreet)
.where('patientCity', '=', tour.patientCity)
.where('patientZip', '=', tour.patientZip)
.where((eb) =>
eb.or([
eb('targetStreet', '=', institutionStreet),
eb('startStreet', '=', institutionStreet),
]),
)
.where('rangeEndDate', '>=', tour.startDate)
.where('ticketId', 'is not', null)
.orderBy('startDate', 'desc')
.executeTakeFirst();
}
async findOnSameDayWithOppositeDirection(tour: Tour): Promise<Tour | null> {
return await 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',
},
],
},
});
const dayStart = startOfDay(new Date(tour.startDate));
const dayEnd = endOfDay(new Date(tour.startDate));
const oppositeDirection =
tour.direction === 'Hinfahrt' ? 'Rückfahrt' : 'Hinfahrt';
return await this.db
.selectFrom('Tour')
.selectAll()
.where('startDate', '>', dayStart)
.where('startDate', '<', dayEnd)
.where('startStreet', '=', tour.targetStreet)
.where('targetStreet', '=', tour.startStreet)
.where('direction', '=', oppositeDirection)
.executeTakeFirst();
}
async findWithTicket(interval: Interval): Promise<Tour[]> {
@@ -389,40 +363,36 @@ export class TourRepository {
take?: number,
skip?: number,
): Promise<{ tours: Tour[]; total: 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),
...onlyRealTours(),
],
},
orderBy: { createdAt: 'desc' },
};
return await this.db.transaction().execute(async (trx) => {
const tours = await trx
.selectFrom('Tour')
.selectAll()
.where((eb) =>
eb.and([
this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb),
]),
)
.offset(skip)
.limit(take)
.execute();
const [tours, count] = await this.prisma.$transaction([
this.prisma.tour.findMany({ ...query }),
this.prisma.tour.count({ where: query.where }),
]);
return {
tours,
total: count,
};
const total = await trx
.selectFrom('Tour')
.where((eb) =>
eb.and([
this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb),
]),
)
.select((eb) => eb.fn.countAll().as('total'))
.executeTakeFirst();
return {
tours,
total: Number(total.total),
};
});
}
async calculateAccountingStateMeta(state: AccountingState): Promise<{
@@ -431,78 +401,90 @@ export class TourRepository {
positiveRevenueDeviation?: number;
negativeRevenueDeviation?: number;
}> {
const checkValue = this.getCheckValueForAccountingState(state);
const ticketWithCurrentStateAndTours: Prisma.TourWhereInput = {
check: checkValue,
...(checkValue === 2 ? { revenueDeviation: { not: 0 } } : {}),
empty: {
not: null,
},
ticketId: {
not: null,
},
AND: [...onlyRealTours()],
};
const [
count,
revenueSum,
positiveRevenueDeviation,
negativeRevenueDeviation,
] = await this.prisma.$transaction([
this.prisma.tour.count({
where: { ...ticketWithCurrentStateAndTours },
}),
this.prisma.tour.aggregate({
_sum: {
revenue: true,
},
where: {
...ticketWithCurrentStateAndTours,
},
}),
this.prisma.tour.aggregate({
_sum: {
revenueDeviation: true,
},
where: {
...ticketWithCurrentStateAndTours,
revenueDeviation: {
gt: 0,
},
},
}),
this.prisma.tour.aggregate({
_sum: {
revenueDeviation: true,
},
where: {
...ticketWithCurrentStateAndTours,
revenueDeviation: {
lt: 0,
},
},
}),
]);
const result = await this.db
.selectFrom('Tour')
.where((eb) => {
const conditions = [
this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb),
eb('empty', 'is not', null),
eb('ticketId', 'is not', null),
];
return eb.and(conditions);
})
.select((eb) => [
eb.fn.countAll().as('count'),
eb.fn.sum<number>('revenue').as('revenueSum'),
eb.fn
.sum<number>(
eb
.case()
.when('revenueDeviation', '>', 0)
.then(eb.ref('revenueDeviation'))
.else(0)
.end(),
)
.as('positiveRevenueDeviation'),
eb.fn
.sum<number>(
eb
.case()
.when('revenueDeviation', '<', 0)
.then(eb.ref('revenueDeviation'))
.else(0)
.end(),
)
.as('negativeRevenueDeviation'),
])
.executeTakeFirstOrThrow();
return {
count,
revenueSum: revenueSum._sum.revenue ?? 0,
positiveRevenueDeviation:
positiveRevenueDeviation._sum.revenueDeviation ?? 0,
negativeRevenueDeviation:
negativeRevenueDeviation._sum.revenueDeviation ?? 0,
count: Number(result.count),
revenueSum: Number(result.revenueSum ?? 0),
positiveRevenueDeviation: Number(result.positiveRevenueDeviation ?? 0),
negativeRevenueDeviation: Number(result.negativeRevenueDeviation ?? 0),
};
}
private getCheckValueForAccountingState(state: AccountingState): number {
private getFilterForAccountingState(
eb: ExpressionBuilder<DB, 'Tour'>,
state: AccountingState,
): ExpressionWrapper<DB, 'Tour', SqlBool> {
switch (state) {
case AccountingState.Pending:
return eb.and([
eb('Tour.check', '=', 0),
eb('Tour.revenueDeviation', '<>', 0),
eb.exists(
eb
.selectFrom('Ticket')
.select('Ticket.id')
.whereRef('Ticket.id', '=', 'Tour.ticketId')
.where(
'Ticket.currentState',
'<>',
TicketValidationState.ARCHIVED,
),
),
]);
case AccountingState.Billable:
return 0;
return eb.and([
eb('Tour.check', '=', 0),
eb('Tour.revenueDeviation', '=', 0),
eb.exists(
eb
.selectFrom('Ticket')
.select('Ticket.id')
.whereRef('Ticket.id', '=', 'Tour.ticketId')
.where(
'Ticket.currentState',
'=',
TicketValidationState.ARCHIVED,
),
),
]);
case AccountingState.PreparedForBilling:
return 1;
case AccountingState.Billed:
return 2;
return eb.and([eb('Tour.check', '=', 1)]);
}
}
}
@@ -1 +1,2 @@
export const TOURS_QUEUE = 'tours';
export const TOUR_FILE_UPLOAD_QUEUE = 'tour_file_upload'
@@ -19,6 +19,8 @@ import { TourRepository } from './entities/tour/tour.repository';
import { TourService } from './entities/tour/tour.service';
import { WorkTimeRepository } from './entities/work-time/work-time.repository';
import { WorkTimeService } from './entities/work-time/work-time.service';
import { TourFilesRepository } from './entities/tour-files/tour-files.repository';
import { TourFilesService } from './entities/tour-files/tour-files.service';
@Module({
imports: [PrismaModule],
@@ -32,6 +34,7 @@ import { WorkTimeService } from './entities/work-time/work-time.service';
TariffService,
TicketService,
TourService,
TourFilesService,
WorkTimeService,
],
providers: [
@@ -52,6 +55,8 @@ import { WorkTimeService } from './entities/work-time/work-time.service';
TicketService,
TourRepository,
TourService,
TourFilesRepository,
TourFilesService,
WorkTimeRepository,
WorkTimeService,
],
@@ -19,12 +19,16 @@ import {
of,
reduce,
switchMap,
tap,
} from 'rxjs';
import { EmployeeDispoLiveRaw, PatientRaw, TourRaw } from './source.interface';
import { SourceRepository } from './source.repository';
import { chunk } from 'src/utils';
import { ConfigService } from 'src/core/config/config.service';
import { TourExtractor } from './extractors/tour.extractor';
import * as fs from 'fs';
import * as FormData from 'form-data';
import * as path from 'path';
@Injectable()
export class DispoLiveExtractorsService {
@@ -36,6 +40,39 @@ export class DispoLiveExtractorsService {
private readonly config: ConfigService,
) {}
uploadFileToTour(tourId: string, storagePath: string, filename: string) {
const fileUploadEndpoint = 'custom/upload/userFiles';
const fileStream = fs.createReadStream(storagePath);
const fileid = path.basename(storagePath);
const formData = new FormData();
formData.append('_id', fileid);
formData.append('coll', 'userFiles');
formData.append('uploadFromColl', 'fahrberichte');
formData.append('dataId', tourId);
formData.append('file', fileStream, filename);
return this.dispoliveLogin(
this.config.dispoliveUsername(),
this.config.dispolivePassword(),
).pipe(
switchMap((authCookies) =>
this.http.post(
`${this.config.dispoliveBaseUrl()}/${fileUploadEndpoint}`,
formData,
{
headers: {
...formData.getHeaders(),
Cookie: authCookies,
},
},
),
),
tap((res) => console.log(res.data)),
);
}
extractTour(id: string, jobId: string) {
return this.fetchTour(id).pipe(map((tour) => ({ ...tour, jobId })));
}
@@ -11,6 +11,10 @@ import { ToursConsumer } from './processors/tours.consumer';
import { SchichtplanungSynchronizer } from './producer/schichtplanung';
import { TourUpdateChecker } from './producer/tour-update';
import { SCHICHTPLANUNG_QUEUE } from './tokens';
import { TourFileUploadConsumer } from './processors/tour-file-upload.consumer';
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
import { MailConsumer } from './processors/mail.consumer';
import { MailModule } from 'src/core/mail/mail.module';
@Module({
imports: [
@@ -18,8 +22,10 @@ import { SCHICHTPLANUNG_QUEUE } from './tokens';
DataAccessExtractorsModule,
DataAccessTransformersModule,
DataAccessLoadersModule,
MailModule,
BullModule.registerQueue({ name: TOURS_QUEUE }),
BullModule.registerQueue({ name: SCHICHTPLANUNG_QUEUE }),
BullModule.registerQueue({ name: MAILING_QUEUE }),
],
providers: [
ImporterService,
@@ -27,6 +33,8 @@ import { SCHICHTPLANUNG_QUEUE } from './tokens';
TourUpdateChecker,
SchichtplanungSynchronizer,
SchichtplanungConsumer,
TourFileUploadConsumer,
MailConsumer
],
exports: [ImporterService],
})
@@ -0,0 +1,49 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
import { MailService } from 'src/core/mail/mail.service';
import { TourFilesService } from 'src/modules/feat-business-objects/entities/tour-files/tour-files.service';
import { TicketService } from 'src/modules/feat-business-objects/entities/ticket/ticket.service';
interface ApprovalJobPayload {
ticketId: string;
tourFileId: string;
recipientEmail: string;
}
@Processor(MAILING_QUEUE)
export class MailConsumer extends WorkerHost {
private readonly logger = new Logger(MailConsumer.name);
constructor(
private readonly mailService: MailService,
private readonly tourFileService: TourFilesService,
private readonly ticketService: TicketService,
) {
super();
}
async process(job: Job<ApprovalJobPayload>): Promise<void> {
const { ticketId, tourFileId, recipientEmail } = job.data;
this.logger.log(
`Processing approval request for Ticket ${ticketId} -> ${recipientEmail}`,
);
try {
const tourFile = await this.tourFileService.findById(tourFileId);
await this.mailService.sendGenehmigungsanfrage(recipientEmail, tourFile);
await this.ticketService.approvalRequestedForTicket(ticketId);
this.logger.log(
`Approval email sent successfully for Ticket ${ticketId}`,
);
} catch (error) {
this.logger.error(
`Failed to send approval email for Ticket ${ticketId}`,
error,
);
throw error;
}
}
}
@@ -0,0 +1,43 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { lastValueFrom } from 'rxjs';
import { TOUR_FILE_UPLOAD_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import { DispoLiveExtractorsService } from '../../feat-extractors/dispo-live-extractors.service';
import { TourFilesService } from 'src/modules/feat-business-objects/entities/tour-files/tour-files.service';
@Processor(TOUR_FILE_UPLOAD_QUEUE)
export class TourFileUploadConsumer extends WorkerHost {
private readonly logger = new Logger(TourFileUploadConsumer.name);
constructor(
private readonly tourFileService: TourFilesService,
private readonly dlService: DispoLiveExtractorsService,
) {
super();
}
async process(job: Job, token?: string): Promise<any> {
const { tourFileId } = job.data;
try {
const tourFile = await this.tourFileService.findById(tourFileId);
this.logger.log(`starting upload for tourId: ${tourFile.tourId}`);
const res = await lastValueFrom(
this.dlService.uploadFileToTour(
tourFile.tourId,
tourFile.storagePath,
tourFile.originalFilename,
),
);
this.logger.log(`file upload success (Status: ${res.status})`);
await this.tourFileService.updateStatus(tourFile.id, 'success');
} catch (error) {
console.error('upload failed', error);
await this.tourFileService.updateStatus(tourFileId, 'failed');
throw error;
}
}
}
@@ -12,7 +12,7 @@ import {
set,
subDays,
subHours,
subWeeks
subWeeks,
} from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service';
import { generateIntervalForTimeRange } from 'src/utils';
@@ -253,13 +253,13 @@ export class KpiService {
ticketState: TicketValidationState,
): Promise<KpiInfoObjectType> {
return {
main: await this.fastMovingTicketPercentMetric(ticketState),
secondary: await this.averageTimePerStateMetric(ticketState),
main: { label: '', value: 0 }, // await this.fastMovingTicketPercentMetric(ticketState),
secondary: { label: '', value: 0 }, // await this.averageTimePerStateMetric(ticketState),
additional: [
await this.ticketCountPerStateMetric(ticketState),
...(ticketState === TicketValidationState.USER_INTERACTION_REQUIRED
? [await this.ticketApprovalRequiredCountMetric(ticketState)]
: []),
// ...(ticketState === TicketValidationState.USER_INTERACTION_REQUIRED
// ? [await this.ticketApprovalRequiredCountMetric(ticketState)]
// : []),
await this.tourCountPerTicketStateMetric(ticketState),
],
};
@@ -317,9 +317,9 @@ export class KpiService {
state: true,
},
orderBy: {
id: 'asc'
id: 'asc',
},
take: 1000
take: 1000,
});
const timePerTickets = movedTickets
.map(({ state }) => {
@@ -59,13 +59,19 @@ export class TicketLifecycleService {
switch (ticket.currentState) {
case TicketValidationState.USER_INTERACTION_REQUIRED:
const approvalStateUpdate = documentInfo.approval.isNeeded
? ApprovalState.REQUESTED
: ApprovalState.FREE;
let approvalStateUpdate: ApprovalState;
if (documentInfo.approval.whoRequested == 'no') {
approvalStateUpdate = ApprovalState.REQUIRED;
} else {
approvalStateUpdate = documentInfo.approval.isNeeded
? ApprovalState.REQUESTED
: ApprovalState.FREE;
}
let stateUpdate: TicketValidationState =
TicketValidationState.USER_INTERACTION_REQUIRED;
if (
if (approvalStateUpdate === ApprovalState.REQUIRED) {
} else if (
documentInfo.tdLocation === 'company' &&
approvalStateUpdate === ApprovalState.FREE
)
@@ -137,29 +143,12 @@ export class TicketLifecycleService {
: ApprovalState.REQUIRED,
});
} else {
// NOTE: Condition required to get these tickets out of USER_INTERACTION_REQUIRED
if (
ticket.tours.every(
({ ordinanceType }) =>
ordinanceType.indexOf('BTW') > -1 ||
ordinanceType.indexOf('TSW') > -1,
) &&
ticket.currentState != TicketValidationState.TD_UNCERTAIN
) {
await this.ticketService.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.TD_UNCERTAIN,
approvalState: ApprovalState.FREE,
});
} else {
await this.ticketService.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.USER_INTERACTION_REQUIRED,
approvalState: ApprovalState.FREE,
});
}
await this.ticketService.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.TD_UNCERTAIN,
approvalState: ApprovalState.FREE,
});
}
} else {
await this.ticketService.setActionablesOnTicket(
@@ -260,13 +249,17 @@ export class TicketLifecycleService {
if (
[
'Einweisung',
'Entlassung',
'Entlassung Vorbestellung',
'Konsil KH - Dia',
'1. Entlassung',
'2. Entlassung Vorbestellung',
'3. Einweisung',
'5. Verlegung',
'6. Barzahlung',
'Privatfahrt Rechnung',
'Tagesklinik',
'Verlegung',
'Reha',
'Tagesklinik - AKW',
'Tagesklinik - ASK',
'Tagesklinik - MKH',
'Transportschein abholen',
].includes(tour.category) ||
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
) {
@@ -5,7 +5,7 @@ import { TicketLifecycleService } from './ticket-lifecycle.service';
import {
TicketSystemEventTypes,
TicketSystemValidatedEvent,
} from './ticket-system.events';
} from '../feat-business-objects/entities/ticket/ticket-system.events';
@Injectable()
export class TicketService {