commit working state

Too many changes to split them up. This commit contains roughly two months worth of work.

This commit introduces the first aggregate and rewrites the ticket system to follow DDD patterns.
It adds Spartan NG as the new component library. TanStack Query to replace GraphQL. Oh and Prisma is
almost over as well.
This commit is contained in:
Marcel Arndt
2026-01-19 12:21:48 +01:00
parent 62e663d053
commit ce676f20a4
334 changed files with 64333 additions and 52554 deletions
+15 -8
View File
@@ -1,11 +1,17 @@
FROM node:20.15.1-alpine AS builder
FROM node:20.19.1-slim AS builder
WORKDIR /app/builder
COPY package.json .
COPY package-lock.json .
RUN npm install
COPY . .
COPY package*.json .
COPY packages/shared-dtos/package*.json ./packages/shared-dtos/
COPY data-hub/data-connector/package*.json ./data-hub/data-connector/
RUN npm ci --verbose
COPY packages/shared-dtos ./packages/shared-dtos/
COPY data-hub/data-connector ./data-hub/data-connector/
WORKDIR /app/builder/data-hub/data-connector
RUN npm run build
@@ -27,9 +33,10 @@ FROM node:20.15.1-alpine
WORKDIR /app
COPY --from=builder /app/builder/node_modules ./node_modules
COPY --from=builder /app/builder/prisma ./prisma
COPY --from=builder /app/builder/dist ./dist
COPY --from=builder /app/builder/docker-entrypoint.sh .
COPY --from=builder /app/builder/packages ./packages
COPY --from=builder /app/builder/data-hub/data-connector/prisma ./prisma
COPY --from=builder /app/builder/data-hub/data-connector/dist ./dist
COPY --from=builder /app/builder/data-hub/data-connector/docker-entrypoint.sh .
ENTRYPOINT [ "./docker-entrypoint.sh" ]
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -6,12 +6,12 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"build": "npm run prisma -- generate && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"start": "TZ=UTC nest start",
"start:dev": "TZ=UTC nest start --watch",
"start:debug": "TZ=UTC nest start --debug --watch",
"start:prod": "TZ=UTC node dist/main",
"prisma": "prisma",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
@@ -24,7 +24,9 @@
"seed": "ts-node prisma/seed/seed.ts"
},
"dependencies": {
"@apollo/server": "^4.12.0",
"@apollo/server": "^5.0.0",
"@as-integrations/express5": "^1.1.2",
"@avicenna/shared-dtos": "^1.0.0",
"@faker-js/faker": "^9.6.0",
"@hyperdx/node-logger": "^0.4.0",
"@hyperdx/node-opentelemetry": "^0.10.2",
@@ -39,12 +41,14 @@
"@nestjs/graphql": "^13.0.4",
"@nestjs/platform-express": "^11.0.15",
"@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.2.5",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.60.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
"@opentelemetry/instrumentation-runtime-node": "^0.22.0",
"@opentelemetry/sdk-node": "^0.202.0",
"@prisma/client": "^6.6.0",
"axios": "^1.13.2",
"bullmq": "^5.56.0",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
@@ -77,7 +81,7 @@
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
"jest": "^29.7.0",
"prettier": "^3.5.3",
"prettier": "^3.8.0",
"prisma": "^6.6.0",
"source-map-support": "^0.5.21",
"supertest": "^7.1.0",
@@ -0,0 +1,47 @@
/*
Warnings:
- A unique constraint covering the columns `[tourId,ticketId]` on the table `TourFile` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "TourFile_tourId_ticketId_idx";
-- AlterTable
ALTER TABLE "Patient" ADD COLUMN "insuranceNo" TEXT;
-- AlterTable
ALTER TABLE "PatientSource" ADD COLUMN "stammVersNr" TEXT;
-- AlterTable
ALTER TABLE "PatientStaging" ADD COLUMN "insuranceNo" TEXT;
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "approvalFullRequestMessageId" TEXT,
ADD COLUMN "approvalIsOverriden" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "approvalLastInteractionAt" TIMESTAMP(3),
ADD COLUMN "approvalPreInquiryMessageId" TEXT,
ADD COLUMN "approvalRecipient" TEXT,
ADD COLUMN "approvalReminderCount" INTEGER DEFAULT 0,
ADD COLUMN "approvalRequestedAt" TIMESTAMP(3),
ADD COLUMN "approvalRequirement" TEXT DEFAULT 'UNKNOWN',
ADD COLUMN "approvalStatus" TEXT DEFAULT 'NOT_STARTED',
ADD COLUMN "hasDigitalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hasPhysicalTransportDocument" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "patientId" UUID,
ADD COLUMN "stage" TEXT DEFAULT 'DRAFT',
ADD COLUMN "updatedAt" TIMESTAMP(3),
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "currentState" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "insuranceNo" TEXT;
-- AlterTable
ALTER TABLE "TourSource" ADD COLUMN "stammVersNr" TEXT;
-- AlterTable
ALTER TABLE "TourStaging" ADD COLUMN "insuranceNo" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "TourFile_tourId_ticketId_key" ON "TourFile"("tourId", "ticketId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "lastStageBeforeClosed" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tour" ADD COLUMN "optimizationInMinutes" INTEGER NOT NULL DEFAULT 0;
@@ -59,6 +59,7 @@ model TourSource {
itName String?
kkId String?
krankenkasse String?
stammVersNr String?
patId String?
patName String?
patSurname String?
@@ -196,6 +197,7 @@ model PatientSource {
birthday String?
kkId String?
krankenkasse String?
stammVersNr String?
pflegestufe String?
behinderung1 String?
behinderung2 String?
@@ -223,6 +225,7 @@ model TourStaging {
patientCity String?
healthInsurance String?
healthInsuranceNumber String?
insuranceNo String?
type String?
category String?
@@ -282,6 +285,7 @@ model PatientStaging {
birthday DateTime?
healthinsuranceId String?
healthinsurance String?
insuranceNo String?
careDegree String?
disabilityMark1 String?
disabilityMark2 String?
@@ -371,6 +375,7 @@ model Tour {
patientCity String?
healthInsurance String?
healthInsuranceNumber String?
insuranceNo String?
type String?
category String?
@@ -414,11 +419,12 @@ model Tour {
companyName String?
schwerlast Boolean @default(false)
createdAt DateTime?
updatedAt DateTime?
deletedAt DateTime?
Ticket Ticket? @relation(fields: [ticketId], references: [id])
ticketId String? @db.Uuid
createdAt DateTime?
updatedAt DateTime?
deletedAt DateTime?
optimizationInMinutes Int @default(0)
Ticket Ticket? @relation(fields: [ticketId], references: [id])
ticketId String? @db.Uuid
}
model TourFile {
@@ -435,7 +441,7 @@ model TourFile {
createdAt DateTime? @default(now())
@@index([tourId, ticketId])
@@unique([tourId, ticketId])
}
model Patient {
@@ -449,6 +455,7 @@ model Patient {
birthday DateTime?
healthinsuranceId String?
healthinsurance String?
insuranceNo String?
careDegree String?
disabilityMark1 String?
disabilityMark2 String?
@@ -480,17 +487,36 @@ enum ApprovalState {
}
model Ticket {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
id String @id @db.Uuid
patientId String? @db.Uuid
stage String? @default("DRAFT")
approvalRequirement String? @default("UNKNOWN")
approvalStatus String? @default("NOT_STARTED")
approvalRequestedAt DateTime?
approvalLastInteractionAt DateTime?
approvalReminderCount Int? @default(0)
approvalRecipient String?
approvalPreInquiryMessageId String?
approvalFullRequestMessageId String?
approvalIsOverriden Boolean @default(false)
hasPhysicalTransportDocument Boolean @default(false)
hasDigitalTransportDocument Boolean @default(false)
lastStageBeforeClosed String?
updatedAt DateTime? @updatedAt
createdAt DateTime @default(now())
tours Tour[]
state TicketStateHistory[]
currentState TicketValidationState
currentState TicketValidationState?
notes String[]
errors String[]
isUrgent Boolean @default(false)
urgency DateTime @default("2099-12-31T23:59:59.999Z")
isUrgent Boolean @default(false)
urgency DateTime @default("2099-12-31T23:59:59.999Z")
approvalState ApprovalState?
documentInfo Json? @default("{\"approval\": {\"isNeeded\": null, \"whoRequested\": \"\",\"answeredFrom\": \"\"},\"tdLocation\": \"\"}")
createdAt DateTime @default(now())
documentInfo Json? @default("{\"approval\": {\"isNeeded\": null, \"whoRequested\": \"\",\"answeredFrom\": \"\"},\"tdLocation\": \"\"}")
}
model TicketStateHistory {
@@ -25,7 +25,7 @@ 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 { OtelMetricsService } from './core/service/otel-metrics.service';
import { AppPlaygroundModule } from './modules/app-playground/app-playground.module';
@Module({
imports: [
@@ -60,7 +60,7 @@ import { OtelMetricsService } from './core/service/otel-metrics.service';
EventEmitterModule.forRoot(),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
playground: true,
graphiql: true,
autoSchemaFile: join(process.cwd(), 'src/core/database/schema.gql'),
sortSchema: true,
subscriptions: {
@@ -78,9 +78,10 @@ import { OtelMetricsService } from './core/service/otel-metrics.service';
ApplicationControlCenterModule,
ApplicationTicketSystemModule,
AppHomeModule,
AppPlaygroundModule,
],
controllers: [AppController],
providers: [AppService, Logger, OtelMetricsService],
providers: [AppService, Logger],
})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
@@ -24,13 +24,6 @@ export interface PrismaServiceOptions {
* See: https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management
*/
explicitConnect?: boolean;
/**
* Apply Prisma middlewares to perform actions before or after db queries.
*
* See: https://www.prisma.io/docs/concepts/components/prisma-client/middleware
*/
middlewares?: Array<Prisma.Middleware>;
}
export interface PrismaOptionsFactory {
@@ -31,12 +31,6 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
],
});
if (this.prismaServiceOptions.middlewares) {
this.prismaServiceOptions.middlewares.forEach((middleware) =>
this.$use(middleware),
);
}
/*
Sadly typescript does not recognize the Prisma Client configuration passed in the super call.
As Prisma defines the events generically, based on the configuration, the event type, typescript inferred, is wrong.
@@ -191,7 +191,7 @@ type PlannedTime {
type Query {
accountingStateMeta(state: AccountingState!): TourStateMeta!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int): PaginatedTour!
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
anomaliesProto: [Anomaly!]!
anomaly(id: String!): Anomaly!
@@ -187,6 +187,7 @@ export type Patient = {
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
insuranceNo: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
@@ -204,6 +205,7 @@ export type PatientSource = {
birthday: string | null;
kkId: string | null;
krankenkasse: string | null;
stammVersNr: string | null;
pflegestufe: string | null;
behinderung1: string | null;
behinderung2: string | null;
@@ -219,6 +221,7 @@ export type PatientStaging = {
birthday: Timestamp | null;
healthinsuranceId: string | null;
healthinsurance: string | null;
insuranceNo: string | null;
careDegree: string | null;
disabilityMark1: string | null;
disabilityMark2: string | null;
@@ -298,15 +301,30 @@ export type TariffGroup = {
ordinanceType: string;
};
export type Ticket = {
id: Generated<string>;
currentState: TicketValidationState;
id: string;
patientId: string | null;
stage: Generated<string | null>;
approvalRequirement: Generated<string | null>;
approvalStatus: Generated<string | null>;
approvalRequestedAt: Timestamp | null;
approvalLastInteractionAt: Timestamp | null;
approvalReminderCount: Generated<number | null>;
approvalRecipient: string | null;
approvalPreInquiryMessageId: string | null;
approvalFullRequestMessageId: string | null;
approvalIsOverriden: Generated<boolean>;
hasPhysicalTransportDocument: Generated<boolean>;
hasDigitalTransportDocument: Generated<boolean>;
lastStageBeforeClosed: string | null;
updatedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
currentState: TicketValidationState | null;
notes: string[];
errors: string[];
isUrgent: Generated<boolean>;
urgency: Generated<Timestamp>;
approvalState: ApprovalState | null;
documentInfo: Generated<unknown | null>;
createdAt: Generated<Timestamp>;
};
export type TicketStateHistory = {
id: Generated<string>;
@@ -332,6 +350,7 @@ export type Tour = {
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
insuranceNo: string | null;
type: string | null;
category: string | null;
transportType: string | null;
@@ -368,6 +387,7 @@ export type Tour = {
createdAt: Timestamp | null;
updatedAt: Timestamp | null;
deletedAt: Timestamp | null;
optimizationInMinutes: Generated<number>;
ticketId: string | null;
};
export type TourFile = {
@@ -415,6 +435,7 @@ export type TourSource = {
itName: string | null;
kkId: string | null;
krankenkasse: string | null;
stammVersNr: string | null;
patId: string | null;
patName: string | null;
patSurname: string | null;
@@ -473,6 +494,7 @@ export type TourStaging = {
patientCity: string | null;
healthInsurance: string | null;
healthInsuranceNumber: string | null;
insuranceNo: string | null;
type: string | null;
category: string | null;
transportType: string | null;
@@ -1,35 +1,97 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, Logger } from '@nestjs/common';
import { TourFile } from '@prisma/client';
import { SentMessageInfo } from 'nodemailer';
import { ConfigService } from '../config/config.service';
export interface VorabGenehmigungsanfrageParams {
patientName: string;
birthday: string;
insuranceNo: string;
healthInsurance: string;
startAddressLine: string;
targetAddressLine: string;
transportDate: string;
}
export interface MailAttachment {
originalFilename: string;
storagePath: string;
mimeType: string;
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private readonly mailerService: MailerService, private readonly config: ConfigService) {}
constructor(
private readonly mailerService: MailerService,
private readonly config: ConfigService,
) {}
async sendGenehmigungsanfrage(recipient: string, transportschein: TourFile) {
async sendGenehmigungsanfrage(
recipient: string,
attachment: MailAttachment,
replyToId?: string,
): Promise<SentMessageInfo> {
this.logger.log(
`sending Genehmigungsanfrage mail to ${recipient} with Transportschein ${transportschein.originalFilename} for Tour ${transportschein.tourId}`,
`sending Genehmigungsanfrage mail to ${recipient} with Transportschein ${attachment.originalFilename}`,
);
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)
}
return await this.mailerService.sendMail({
to: recipient,
bcc: 'info@avicenna.hamburg',
subject: `Antrag auf Kostenübernahme`,
template: 'genehmigungsanfrage',
headers: this.getThreadingHeaders(replyToId),
attachments: [
{
filename: attachment.originalFilename,
path: attachment.storagePath,
contentType: attachment.mimeType,
},
],
});
}
async sendVorabGenehmigungsanfrage(
recipient: string,
params: VorabGenehmigungsanfrageParams,
replyToId?: string,
): Promise<SentMessageInfo> {
this.logger.log(
`sending Vorab-Genehmigungsanfrage mail to ${recipient} for Patient ${params.patientName} and transport at ${params.transportDate}`,
);
return await this.mailerService.sendMail({
to: recipient,
bcc: 'info@avicenna.hamburg',
headers: this.getThreadingHeaders(replyToId),
subject: `Antrag auf Kostenübernahme Transport am ${params.transportDate} für ${params.patientName}`,
template: 'genehmigung-vorab-anfrage',
context: params,
});
}
async sendReminder(
recipient: string,
params: { patientName: string; transportDate: Date; reminderCount: number },
replyToId?: string,
): Promise<SentMessageInfo> {
this.logger.log(`sending Reminder ${params.reminderCount} to ${recipient}`);
return await this.mailerService.sendMail({
to: recipient,
bcc: 'info@avicenna.hamburg',
subject: `Erinnerung: Antrag für ${params.patientName}`, // Ggf. gleicher Betreff wie Vorher für Threading
template: 'genehmigung-reminder',
headers: this.getThreadingHeaders(replyToId),
context: params,
});
}
private getThreadingHeaders(replyToMessageId?: string) {
if (!replyToMessageId) return {};
return {
'In-Reply-To': replyToMessageId,
References: replyToMessageId,
};
}
}
@@ -1,18 +0,0 @@
<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,18 @@
<div style="font-family: sans-serif; white-space: pre-wrap;">
Sehr geehrte Damen und Herren,<br>
<br>
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.<br>
<br>
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.<br>
<br>
Bitte senden Sie die Unterlagen an eine der folgenden Stellen:<br>
• E-Mail: info@avicenna.hamburg<br>
• Fax: 040 460006820<br>
• Postanschrift: Thomas-Mann-Straße 21, 22175 Hamburg<br>
• Institutionskennzeichen: 600224841<br>
<br>
Für Rückfragen stehen wir Ihnen selbstverständlich gerne zur Verfügung.<br>
Vielen Dank vorab.<br>
Mit freundlichen Grüßen<br>
Ihr Team von Avicenna<br>
</div>
@@ -0,0 +1,27 @@
<div style="font-family: sans-serif; white-space: pre-wrap">
Sehr geehrte Damen und Herren,<br>
<br>
hiermit beantragen wir im Auftrag des Versicherten {{patientName}} die Kostenübernahme gemäß § 6 Abs. 3 der Krankentransport-Richtlinie für den geplanten Transport am {{transportDate}}.<br>
<br>
Eine ärztliche Verordnung liegt uns zum jetzigen Zeitpunkt noch nicht vor. Diese wird nachgereicht, sobald sie uns übermittelt wurde.<br>
<br>
Für die Vorabprüfung übermitteln wir Ihnen die relevanten Eckdaten:<br>
<br>
Patient: {{patientName}}<br>
Geburtsdatum: {{birthday}}<br>
Versicherungsnummer: {{insuranceNo}}<br>
Krankenkasse: {{healthInsurance}}<br>
Abholadresse: {{startAddressLine}}<br>
Zieladresse: {{targetAddressLine}}<br>
Datum & Uhrzeit: {{transportDate}}<br>
<br>
Bitte senden Sie uns die Bestätigung 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>
Vielen Dank für Ihre Unterstützung.<br>
Mit freundlichen Grüßen<br>
Ihr Team der Avicenna Ambulance<br>
</div>
+10 -3
View File
@@ -1,13 +1,11 @@
// const tracer = require('./tracer');
// import tracer from './tracer'
import { HyperDXNestLoggerModule } from '@hyperdx/node-logger';
import * as HyperDX from '@hyperdx/node-opentelemetry';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import './instrumentation';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const logger = HyperDXNestLoggerModule.createLogger({
apiKey: '71addf09-bcd2-4e83-8158-97458402137b',
service: 'avicenna-data-connector',
@@ -16,6 +14,15 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: logger,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
transformOptions: {
enableImplicitConversion: false,
},
}),
);
app.enableCors();
HyperDX.setupExpressErrorHandler(app);
@@ -1,14 +1,7 @@
import {
ArgsType,
Field,
Float,
InputType,
Int,
ObjectType,
} from '@nestjs/graphql';
import { ArgsType, Field, InputType } from '@nestjs/graphql';
import { PaginationArgs } from 'src/core/base/pagination.input';
import { AccountingState } from './accounting-state';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingState } from './accounting-state';
@InputType()
export class AccountingFilterArgs implements TourAccountingFilterDto {
@@ -27,15 +20,3 @@ export class AccountingTourArgs extends PaginationArgs {
@Field(() => AccountingFilterArgs, { nullable: true })
filters!: AccountingFilterArgs;
}
@ObjectType('TourStateMeta')
export class TourStateMetaObjectType {
@Field(() => Int)
count!: number;
@Field(() => Float, { nullable: true })
revenueSum?: number;
@Field(() => Float, { nullable: true })
positiveRevenueDeviation?: number;
@Field(() => Float, { nullable: true })
negativeRevenueDeviation?: number;
}
@@ -0,0 +1,64 @@
import { Inject, Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { AccountingState } from './accounting-state';
import {
AccountingListDto,
PaginatedResult,
TicketStage,
} from '@avicenna/shared-dtos';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { Tour } from '@prisma/client';
@Injectable()
export class AccountingQueryService {
constructor(private readonly tourService: TourService) {}
async toursByState(
state: AccountingState,
page: number,
limit: number = 10,
): Promise<PaginatedResult<AccountingListDto>> {
const offset = (page - 1) * limit;
const { tours, total } = await this.tourService.findByAccountingState(
state,
limit,
offset,
);
return {
data: tours.map((tour) => this.toAccountingListDto(tour)),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
private toAccountingListDto(
tour: Tour & { stage: string },
): AccountingListDto {
return {
tourId: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
revenue: tour.revenue,
isTicketOpen: [
TicketStage.DRAFT,
TicketStage.DATA_INCOMPLETE,
TicketStage.ASSESSMENT_PENDING,
TicketStage.APPROVAL_IN_PROGRESS,
TicketStage.DOCUMENTS_PENDING,
].includes(tour.stage as TicketStage),
hasRevenueDeviation:
Math.abs(tour.revenue - tour.revenueDispoLive) > 0.01,
hasOptimizationPotential: tour.optimizationInMinutes !== 0,
};
}
}
@@ -1,12 +1,22 @@
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
Args,
Int,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
import {
AccountingTourArgs,
TourStateMetaObjectType,
AccountingFilterArgs
} from './accounting-arg.types';
import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service';
import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
import {
PaginatedTourObjectType,
TourCostBreakdownObjectType,
TourStateMetaObjectType,
} from './accounting.object-type';
import { TourObjectType } from './tour.object-type';
@Resolver(() => TourObjectType)
@@ -15,9 +25,13 @@ export class AccountingValidationResolver {
@Query(() => PaginatedTourObjectType)
accountingTours(
@Args() { state, filters, cursor, take }: AccountingTourArgs,
@Args('state', { type: () => AccountingState }) state: AccountingState,
@Args('filters', { type: () => AccountingFilterArgs, nullable: true })
filters: AccountingFilterArgs,
@Args('cursor', { nullable: true }) cursor: string,
@Args('take', { type: () => Int, nullable: true }) take: number = 10,
) {
return this.service.paginateToursByState(state, filters, take, cursor);
return this.service.paginateToursByState(state, take, cursor);
}
@Query(() => TourStateMetaObjectType)
@@ -30,7 +44,7 @@ export class AccountingValidationResolver {
@Query(() => TourCostBreakdownObjectType)
tourCostBreakdown(@Args('operationId') operationId: string) {
return this.service.getTourCostBreakdown(operationId)
return this.service.getTourCostBreakdown(operationId);
}
@ResolveField(() => TicketObjectType, { nullable: true })
@@ -1,15 +1,18 @@
import {
PaginatedResult,
PaginatedTourDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import {
decodeOffsetCursor,
encodeCursor,
} from 'src/core/base/pagination.util';
import { TourStateMetaObjectType } from './accounting-arg.types';
import { AccountingState } from './accounting-state';
import { PaginatedTourObjectType } from './accounting.object-type';
import { AccountingService } from './accounting.service';
import { TicketService } from '../feat-business-objects/entities/ticket/ticket.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { TourAccountingFilterDto } from '../feat-business-objects/entities/tour/tours.filter';
import { AccountingState } from './accounting-state';
import { AccountingService } from './accounting.service';
@Injectable()
export class AccountingValidationService {
@@ -19,16 +22,39 @@ export class AccountingValidationService {
private readonly tourService: TourService,
) {}
async paginateToursByState(
async toursByState(
state: AccountingState,
filter: TourAccountingFilterDto,
take: number = 10,
cursor?: string,
): Promise<PaginatedTourObjectType> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
page: number,
limit: number = 10,
): Promise<PaginatedResult<TourDto>> {
const offset = (page - 1) * limit;
const { tours, total } = await this.tourService.findByAccountingState(
state,
limit,
offset,
);
return {
data: tours,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async paginateToursByState(
state: AccountingState,
take: number = 10,
cursor?: string,
): Promise<PaginatedTourDto> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { tours, total } = await this.tourService.findByAccountingState(
state,
filter,
take,
skip,
);
@@ -47,7 +73,7 @@ export class AccountingValidationService {
async getAccountingStateMeta(
accountingState: AccountingState,
): Promise<TourStateMetaObjectType> {
): Promise<TourStateMetaDto> {
return this.tourService.calculateAccountingStateMeta(accountingState);
}
@@ -0,0 +1,96 @@
import {
OptimizationCarDto,
PaginatedTourDto,
TourCostBreakdownDto,
TourDto,
TourStateMetaDto,
} from '@avicenna/shared-dtos';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { AccountingQueryService } from './accounting-query.service';
import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service';
import { RevenueOptimizationService } from './revenue-optimization.service';
@ApiTags('Accounting')
@Controller('api/accounting')
export class AccountingController {
constructor(
private readonly service: AccountingValidationService,
private readonly queryService: AccountingQueryService,
private readonly optimizationService: RevenueOptimizationService,
private readonly tourService: TourService,
) {}
@Get('cars/:name')
async getCarForOptimization(
@Param('name') name: string,
@Query('operation-day') operationDay: string,
): Promise<OptimizationCarDto> {
const date = new Date(operationDay);
const tours = await this.tourService.getOptimizationToursForCar(name, date);
return {
name,
tours: tours.map((tour) => ({
id: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate.toISOString(),
optimizationInMinutes: tour.optimizationInMinutes ?? 0,
departedForPickup: tour.startBegin
? resolveDateFromTime(tour.startDate, tour.startBegin).toISOString()
: null,
arrivedAtPickup: tour.startEnd
? resolveDateFromTime(tour.startDate, tour.startEnd).toISOString()
: null,
departedPickup: tour.target
? resolveDateFromTime(tour.startDate, tour.target).toISOString()
: null,
arrivedAtTarget: tour.targetBegin
? resolveDateFromTime(tour.startDate, tour.targetBegin).toISOString()
: null,
completed: tour.empty
? resolveDateFromTime(tour.startDate, tour.empty).toISOString()
: null,
})),
};
}
@Get('tours')
@ApiOkResponse({ type: PaginatedTourDto })
async getAccountingTours(
@Query('state') state: AccountingState,
@Query('page') page: number,
@Query('take') take: number = 10,
) {
return this.queryService.toursByState(state, page, take);
}
@Get('tours/:id')
@ApiOkResponse({ type: TourDto })
getTourDetail(@Param('id') tourId: string): Promise<TourDto> {
return this.tourService.findById(tourId);
}
@Get('state-meta')
@ApiOkResponse({ type: TourStateMetaDto })
async getAccountingStateMeta(@Query('state') state: AccountingState) {
return this.service.getAccountingStateMeta(state);
}
@Get('cost-breakdown/:operationId')
@ApiOkResponse({ type: TourCostBreakdownDto })
async getTourCostBreakdown(@Param('operationId') operationId: string) {
return this.service.getTourCostBreakdown(operationId);
}
@Get('optimizations')
async getOptimizations(@Query('day') day: Date) {
return this.optimizationService.findOptimizationsForOperationDay(day);
}
}
@@ -0,0 +1,53 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { eachDayOfInterval, startOfDay, subWeeks } from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
import {
RevenueOptimizationDto,
RevenueOptimizationService,
} from './revenue-optimization.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
@Injectable()
export class AccountingListener {
private readonly logger = new Logger(AccountingListener.name);
constructor(
private readonly service: RevenueOptimizationService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly tourService: TourService,
) {}
@OnEvent(ETLEventTypes.LoadingDone)
async handleRevenueOptimizationCheck() {
this.logger.log(`looking for revenue optimizations`);
const batchSize = 10;
const days = eachDayOfInterval({
start: subWeeks(startOfDay(new Date()), 4),
end: startOfDay(new Date()),
});
const optimizationsInPeriod: RevenueOptimizationDto[] = [];
for (let i = 0; i < days.length; i += batchSize) {
const batch = days.slice(i, i + batchSize);
const optimizations = await Promise.all(
batch.map((day) => this.service.findOptimizationsForOperationDay(day)),
);
optimizationsInPeriod.push(...optimizations.flat(2));
}
await Promise.all(
optimizationsInPeriod.map((optimization) =>
this.tourService.updateTour(optimization.tourId, {
optimizationInMinutes: optimization.timeModificationInMinutes,
}),
),
);
return optimizationsInPeriod;
}
}
@@ -1,8 +1,14 @@
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AccountingState } from './accounting-state';
import { Paginated } from 'src/core/base/pagination.input';
import { TourObjectType } from './tour.object-type';
import {
Field,
Float,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { CostTypeVariant } from '@prisma/client';
import { Paginated } from 'src/core/base/pagination.input';
import { AccountingState } from './accounting-state';
import { TourObjectType } from './tour.object-type';
registerEnumType(AccountingState, {
name: 'AccountingState',
@@ -13,7 +19,7 @@ export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
@ObjectType('ServicePosition')
export class ServicePositionObjectType {
@Field()
@Field(() => String)
variant: CostTypeVariant;
@Field()
description: string;
@@ -31,3 +37,15 @@ export class TourCostBreakdownObjectType {
@Field(() => [ServicePositionObjectType])
servicePositions: ServicePositionObjectType[];
}
@ObjectType('TourStateMeta')
export class TourStateMetaObjectType {
@Field(() => Int)
count!: number;
@Field(() => Float, { nullable: true })
revenueSum?: number;
@Field(() => Float, { nullable: true })
positiveRevenueDeviation?: number;
@Field(() => Float, { nullable: true })
negativeRevenueDeviation?: number;
}
@@ -4,14 +4,22 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { AccountingValidationResolver } from './accounting-validation.resolver';
import { AccountingValidationService } from './accounting-validation.service';
import { AccountingController } from './accounting.controller';
import { AccountingListener } from './accounting.listener';
import { AccountingService } from './accounting.service';
import { RevenueOptimizationService } from './revenue-optimization.service';
import { AccountingQueryService } from './accounting-query.service';
@Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [AccountingController],
providers: [
AccountingValidationResolver,
AccountingValidationService,
AccountingService,
AccountingListener,
RevenueOptimizationService,
AccountingQueryService
],
exports: [],
})
@@ -0,0 +1,208 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CostTypeVariant, Tour } from '@prisma/client';
import {
differenceInMinutes,
isAfter,
isBefore,
isWithinInterval,
} from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { generateIntervalForTimeRange } from 'src/utils';
import { resolveDateFromTime } from 'src/utils/resolve-timestamp';
import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service';
export interface RevenueOptimizationDto {
tourId: string;
timeModificationInMinutes: number;
costDifference: number;
}
@Injectable()
export class RevenueOptimizationService {
private readonly logger = new Logger(RevenueOptimizationService.name);
private healthInsurancesWithNoTenant = [];
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly tariffService: TariffService,
) {}
async findOptimizationsForOperationDay(
day: Date = new Date('2026-01-15'),
): Promise<RevenueOptimizationDto[]> {
const tours = await this.loadToursForOperationDay(day);
const groupedByCar = tours.reduce(
(acc, tour) => {
(acc[tour.carName] ??= []).push(tour);
return acc;
},
{} as Record<string, typeof tours>,
);
const result = [];
for (const groupName of Object.keys(groupedByCar)) {
const optimizations = await this.findOptimizationPotential(
groupedByCar[groupName],
);
if (optimizations.length > 0) {
result.push(optimizations);
}
}
return result;
}
async findOptimizationPotential(
tours: Tour[],
): Promise<RevenueOptimizationDto[]> {
const result: RevenueOptimizationDto[] = [];
for (let i = 0; i < tours.length; i++) {
const currentTour = tours[i];
const bonusPeriods = await this.getBonusPeriods(currentTour);
// TODO: Make sure startEnd is the correct timestamp
const tourInvoiceTime = resolveDateFromTime(
currentTour.startDate,
currentTour.startEnd,
);
const bonusPeriod = bonusPeriods
.filter(({ interval }) => isWithinInterval(tourInvoiceTime, interval))
.at(0);
const possiblePeriod = bonusPeriods
.filter(
({ interval }) =>
(Math.abs(differenceInMinutes(tourInvoiceTime, interval.start)) <=
15 ||
Math.abs(differenceInMinutes(tourInvoiceTime, interval.end)) <
15) &&
!isWithinInterval(tourInvoiceTime, interval),
)
.at(0);
if (
(!bonusPeriod && possiblePeriod) ||
bonusPeriod?.costAmount < possiblePeriod?.costAmount
) {
let timeModification = 0;
if (isBefore(tourInvoiceTime, possiblePeriod.interval.start)) {
timeModification = differenceInMinutes(
possiblePeriod.interval.start,
tourInvoiceTime,
);
} else if (isAfter(tourInvoiceTime, possiblePeriod.interval.end)) {
timeModification = differenceInMinutes(
possiblePeriod.interval.end,
tourInvoiceTime,
);
}
let costDifference = 0;
if (bonusPeriod) {
costDifference = possiblePeriod.costAmount - bonusPeriod.costAmount;
} else {
costDifference = possiblePeriod.costAmount;
}
result.push({
tourId: currentTour.id,
timeModificationInMinutes: timeModification,
costDifference,
});
}
}
return result;
}
private async getBonusPeriods(tour: Tour) {
if (!tour.ordinanceType) {
this.logger.warn(`Tour ${tour.operationId} has no ordinanceType`, {
operationId: tour.operationId,
healthInsurance: tour.healthInsurance,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
});
return [];
}
const tariff = await this.findTariff(
tour.healthInsurance,
tour.ordinanceType.split(' ').at(1),
tour.startDate,
);
if (!tariff) {
this.logger.warn(`no Tariff found for Tour ${tour.operationId}`, {
operationId: tour.operationId,
healthInsurance: tour.healthInsurance,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
});
return [];
}
const bonusPeriods = tariff.costTypes
.filter(
({ variant }) =>
variant === CostTypeVariant.NACHTZUSCHLAG ||
variant === CostTypeVariant.GRUNDPREIS_ZEIT,
)
.map(({ timeRange, costAmount, description }) => ({
description,
interval: generateIntervalForTimeRange(
timeRange,
'Europe/Berlin',
tour.startDate,
),
costAmount,
}));
return bonusPeriods;
}
private async loadToursForOperationDay(day: Date) {
const operationDayInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
day,
);
const rows = await this.db
.selectFrom('Tour')
.selectAll()
.where('Tour.startDate', '>=', new Date(operationDayInterval.start))
.where('Tour.startDate', '<=', new Date(operationDayInterval.end))
.where('Tour.healthInsurance', 'is not', null)
.execute();
return rows;
}
private async findTariff(
healthInsurance: string,
ordinanceType: string,
tourDate: Date,
) {
if (!healthInsurance || healthInsurance === '') {
return;
}
const tenant = await this.tariffService.findTenant(
healthInsurance,
ordinanceType,
);
if (!tenant) {
this.healthInsurancesWithNoTenant.push({
healthInsurance,
ordinanceType,
});
return;
}
return this.tariffService.findByTenantId(
tenant.id,
ordinanceType,
tourDate,
);
}
}
@@ -3,6 +3,8 @@ import { Tour } from '@prisma/client';
@ObjectType('Tour')
export class TourObjectType implements Tour {
optimizationInMinutes: number;
insuranceNo: string;
overRTW: boolean;
companyName: string;
schwerlast: boolean;
@@ -139,6 +139,7 @@ export class ControlCenterResolver {
AnomalyTypes.EmployeesLoggedIn,
AnomalyTypes.OneTourAtATime,
AnomalyTypes.IsGermanZip,
AnomalyTypes.KtwTourNotWithKtwCar
],
);
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { ManagerController } from './manager.controller';
import { ManagerResolver } from './manager.resolver';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@@ -9,6 +10,7 @@ import { TourAmountForecastService } from './tour-amount-forecast.service';
@Module({
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
controllers: [ManagerController],
providers: [
TourAmountForecastService,
TimePerTourKpiService,
@@ -0,0 +1,59 @@
import {
TimePerTourKPISummaryDto,
TimePerTourMetricDto,
} from '@avicenna/shared-dtos';
import {
BadRequestException,
Controller,
Get,
Logger,
Query,
} from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@Controller('api/manager')
export class ManagerController {
private readonly logger = new Logger(ManagerController.name);
constructor(
private readonly timePerTourKpiService: TimePerTourKpiService,
private readonly timePerTourMetricService: TimePerTourMetricsService,
) {}
@Get('overview')
@ApiOkResponse({ type: [TimePerTourKPISummaryDto] })
async overview(
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourKPISummaryDto[]> {
if (!ordinanceType) {
throw new BadRequestException(`no ordinanceType provided`);
}
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
}
@Get('dayview')
@ApiOkResponse({ type: [TimePerTourMetricDto] })
async dayview(
@Query('day') day: Date,
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourMetricDto[]> {
if (!day || !ordinanceType) {
throw new BadRequestException(`no day or ordinanceType provided`);
}
return this.timePerTourMetricService.forDay(day, ordinanceType);
}
@Get('monthview')
@ApiOkResponse({ type: [TimePerTourMetricDto] })
async monthview(
@Query('day') day: Date,
@Query('ordinanceType') ordinanceType: string,
): Promise<TimePerTourMetricDto[]> {
if (!day || !ordinanceType) {
throw new BadRequestException(`no day or ordinanceType provided`);
}
return this.timePerTourMetricService.forMonth(day, ordinanceType);
}
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { addMilliseconds } from 'date-fns';
import { addMilliseconds, Interval } from 'date-fns';
import { PubSub } from 'graphql-subscriptions';
import { generateIntervalForTimeRange } from 'src/utils';
import { CalculationService } from '../feat-calculation/calculation.service';
@@ -115,7 +115,10 @@ export class TimePerTourKpiService {
tourAmount,
);
return {
interval,
interval: {
start: new Date(interval.start),
end: new Date(interval.end)
},
kpi: {
value: baseValue,
meta: {
@@ -1,3 +1,4 @@
import { TimePerTourMetricDto } from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import {
addDays,
@@ -15,11 +16,10 @@ import {
startOfDay,
startOfMonth,
} from 'date-fns';
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';
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
import { CalculationService } from '../feat-calculation/calculation.service';
import { TourAmountForecastService } from './tour-amount-forecast.service';
@Injectable()
export class TimePerTourMetricsService {
@@ -84,7 +84,7 @@ export class TimePerTourMetricsService {
intervalClamp: Interval,
subIntervals: Interval[],
ordinanceType: string,
): Promise<TimePerTourMetricObjectType[]> {
): Promise<TimePerTourMetricDto[]> {
const carEntities = await this.carEntityService.getCarsInInterval(
intervalClamp,
ordinanceType,
@@ -180,7 +180,10 @@ export class TimePerTourMetricsService {
}
return {
interval,
interval: {
start: new Date(interval.start),
end: new Date(interval.end),
},
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
breakEven:
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { PlaygroundController } from './playground.controller';
import { PlaygroundService } from './playground.service';
@Module({
imports: [FeatBusinessObjectsModule],
controllers: [PlaygroundController],
providers: [PlaygroundService],
})
export class AppPlaygroundModule {}
@@ -0,0 +1,12 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PlaygroundService } from './playground.service';
@Controller('api/playground')
export class PlaygroundController {
constructor(private readonly service: PlaygroundService) {}
@Get('operation-day')
async getOperationDay(@Query('day') day: Date) {
return this.service.getCarsByOperationDay(new Date(day));
}
}
@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
import { CarEntity } from '../feat-business-objects/car-entity/car.entity';
@Injectable()
export class PlaygroundService {
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly carService: CarEntityService,
) {}
async getCarsByOperationDay(day: Date) {
const ordinanceTypes = ['KTW', 'TSW', 'BTW'];
const cars = await Promise.all(
ordinanceTypes.map((ot) =>
this.carService.getCarsForOperationDay(day, ot),
),
);
return cars
.flat(2)
.filter(({ carName }) => !!carName)
.map((car) => this.carMapper(car))
.filter(
({ tours }) =>
tours.filter(
({ optimizationInMinutes }) => optimizationInMinutes !== 0,
).length > 0,
);
}
private carMapper(car: CarEntity) {
return {
name: car.carName,
drivers: car['plannedTimes'].map((pt) => ({
name: `${pt.firstname} ${pt.surname}`,
})),
tours: car['tours'].map((tour) => ({
id: tour.id,
operationId: tour.operationId,
ordinanceType: tour.ordinanceType,
startDate: tour.startDate,
optimizationInMinutes: tour.optimizationInMinutes,
departedForPickup: tour.startBegin,
arrivedAtPickup: tour.startEnd,
departedPickup: tour.target,
arrivedAtTarget: tour.targetBegin,
completed: tour.empty,
})),
};
}
}
@@ -1,18 +1,19 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
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 { PrismaModule } from 'src/core/database/prisma.module';
import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
import { createMulterOptions } from 'src/core/storage/multer.config';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { FeatTicketSystemModule } from '../feat-tickets/ticket-system.module';
import { TicketQueryService } from './queries/ticket-query.service';
import { TicketSystemService } from './ticket-system.service';
import { TicketRepository } from './ticket.repository';
import { TicketsController } from './tickets.controller';
import { TicketsResolver } from './tickets.resolver';
import { TicketReadMapper } from './mappers/ticket-read.mapper';
@Module({
imports: [
@@ -24,14 +25,17 @@ import { MAILING_QUEUE } from 'src/core/mail/mail.tokens';
useFactory: (config: ConfigService) => createMulterOptions(config),
inject: [ConfigService],
}),
BullModule.registerQueue({
name: TOUR_FILE_UPLOAD_QUEUE,
}),
BullModule.registerQueue({
name: MAILING_QUEUE,
}),
],
providers: [TicketRepository, TicketSystemService, TicketsResolver],
providers: [
TicketRepository,
TicketSystemService,
TicketReadMapper,
TicketsResolver,
TicketQueryService,
],
controllers: [TicketsController],
exports: [TicketSystemService],
})
@@ -0,0 +1,217 @@
import {
ApprovalRequestStatus,
ApprovalRequirement,
TicketApprovalViewDto,
TicketDetailDto,
TicketTransportDocumentView,
} from '@avicenna/shared-dtos';
import { Injectable } from '@nestjs/common';
import { isDate } from 'date-fns';
import { Ticket } from 'src/modules/feat-tickets/domain/ticket.entity';
@Injectable()
export class TicketReadMapper {
toDetailDto(ticket: Ticket): TicketDetailDto {
const tour = ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.at(0);
return {
id: ticket.id.value,
stage: ticket.currentStage,
general: {
ordinanceType: tour.ordinanceType,
transportType: tour.transportType,
category: tour.category,
hasInfection: tour.hasInfection,
infectionName: tour.infectionName,
isSeries: isDate(tour.seriesEndDate),
seriesEndDate: tour.seriesEndDate,
},
validationErrors: ticket.validationErrors,
patient: {
id: ticket.patientId.value,
name: `${ticket.patient.firstName} ${ticket.patient.lastName}`,
healthInsurance: ticket.patient.healthInsurance,
address: {
street: ticket.patient.street,
city: ticket.patient.city,
zip: ticket.patient.zip,
},
},
approval: this.toApprovalView(ticket),
transportDocument: this.toTransportDocumentView(ticket),
tours: ticket.tours
.sort((a, b) => b.startDate.getTime() - a.startDate.getTime())
.map((tour) => ({
id: tour.id.value,
operationId: tour.operationId,
date: tour.startDate.toISOString(),
startAddress: {
street: tour.startStreet,
city: tour.startCity,
zip: tour.startZip,
},
targetAddress: {
street: tour.targetStreet,
city: tour.targetCity,
zip: tour.targetZip,
},
})),
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
};
}
toApprovalView(ticket: Ticket): TicketApprovalViewDto {
const approval = ticket.approval;
return {
requirement: approval.requirement,
status: approval.status,
statusLabel: this.translateApprovalStatus(approval.status),
actions: {
override: {
isPossible: ApprovalRequestStatus.NOT_STARTED === approval.status,
label:
ApprovalRequirement.REQUIRED === approval.requirement
? 'Genehmigung wird nicht benötigt'
: 'Genehmigung wird benötigt',
confirmationMessage:
'Sind Sie sicher, dass sie die Genehmigungsanforderung des Tickets überschreiben wollen?',
},
registerExternalRequest: {
isPossible:
approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT &&
approval.status !== ApprovalRequestStatus.EXTERNAL_REQUEST &&
approval.status !== ApprovalRequestStatus.GRANTED &&
approval.requirement === ApprovalRequirement.REQUIRED,
label: 'Bereits außerhalb angefragt',
},
sendRequest: this.canSendApprovalRequest(ticket),
sendReminder: this.canSendApprovalReminder(ticket),
approve: {
isPossible:
approval.requirement !== ApprovalRequirement.NOT_REQUIRED &&
approval.status !== ApprovalRequestStatus.GRANTED,
},
},
};
}
toTransportDocumentView(ticket: Ticket): TicketTransportDocumentView {
const td = ticket.transportDocument;
const isPresent =
ticket.hasDigitalTransportDocument || ticket.hasPhysicalTransportDocument;
return {
isDocumentPresent: isPresent,
label: isPresent ? 'Transportschein liegt vor' : 'Dokument erforderlich',
digitalDownloadLink: td?.storagePath,
actions: {
upload: {
isPossible: !ticket.hasDigitalTransportDocument,
},
receivePhysical: {
isPossible: !ticket.hasPhysicalTransportDocument,
label: 'Transportschein liegt vor (Erfassen)',
},
},
};
}
private translateApprovalStatus(status: ApprovalRequestStatus): string {
switch (status) {
case ApprovalRequestStatus.NOT_STARTED:
return 'Noch keine Anfrage gesendet';
case ApprovalRequestStatus.PRE_INQUIRY_SENT:
return 'Vorab-Anfrage versendet';
case ApprovalRequestStatus.FULL_REQUEST_SENT:
return 'Anfrage versendet';
case ApprovalRequestStatus.EXTERNAL_REQUEST:
return 'Anfrage von Extern versendet';
case ApprovalRequestStatus.GRANTED:
return 'Genehmigung erteilt';
}
}
private canSendApprovalRequest(ticket: Ticket): {
isPossible: boolean;
label: string;
description: string;
} {
const approval = ticket.approval;
let isPossible = false;
if (approval.requirement === ApprovalRequirement.REQUIRED) {
if (approval.status === ApprovalRequestStatus.NOT_STARTED) {
isPossible = true;
} else if (
approval.status === ApprovalRequestStatus.PRE_INQUIRY_SENT &&
ticket.hasDigitalTransportDocument
) {
isPossible = true;
}
}
let label = 'Anfrage senden';
if (
ticket.approval.status === ApprovalRequestStatus.NOT_STARTED &&
!ticket.hasDigitalTransportDocument
) {
label = 'Vorab-Anfrage senden';
}
let description = undefined;
if (
approval.status === ApprovalRequestStatus.PRE_INQUIRY_SENT &&
!ticket.hasDigitalTransportDocument
) {
description = 'Transportschein fehlt';
}
return {
isPossible: isPossible,
label: label,
description: description,
};
}
private canSendApprovalReminder(ticket: Ticket): {
isPossible: boolean;
label: string;
description: string;
} {
const approval = ticket.approval;
let isPossible = false;
if (
approval.status === ApprovalRequestStatus.FULL_REQUEST_SENT &&
approval.isOverdue() &&
approval.hasRemindersLeft()
) {
isPossible = true;
}
const label = `${approval.reminderCount + 1}. Erinnerung senden`;
return {
isPossible: isPossible,
label: label,
description: undefined,
};
}
}
@@ -0,0 +1,199 @@
import {
PaginatedResult,
TicketListItemDto,
TicketStage,
} from '@avicenna/shared-dtos';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { differenceInDays } from 'date-fns';
import { Kysely, sql } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
@Injectable()
export class TicketQueryService {
private readonly logger = new Logger(TicketQueryService.name);
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async searchTicket(searchTerm: string): Promise<TicketListItemDto[]> {
const searchTerms = searchTerm
.split(' ')
.filter(Boolean)
.map((str) => `%${str}%`);
const rows = await this.db
.selectFrom('Ticket')
.distinctOn('Ticket.id')
.where('stage', 'not in', [TicketStage.ARCHIVED, TicketStage.CLOSED])
.leftJoin('Patient', (join) =>
join.onRef(sql`"Patient"."id"::uuid`, '=', 'Ticket.patientId'),
)
.innerJoin('Tour', 'Ticket.id', 'Tour.ticketId')
.where((eb) =>
eb.and(
searchTerms.map((term) =>
eb.or([
eb('Patient.name', 'ilike', term),
eb('Patient.surname', 'ilike', term),
eb('Tour.patientName', 'ilike', term),
eb('Tour.patientSurname', 'ilike', term),
eb('Tour.operationId', 'ilike', term),
]),
),
),
)
.select((eb) => [
'Ticket.id',
'Ticket.stage',
'Ticket.approvalRequirement',
'Ticket.approvalStatus',
'Ticket.approvalRequestedAt',
'Ticket.createdAt',
'Ticket.updatedAt',
sql<string>`concat("Tour"."patientName", ' ', "Tour"."patientSurname")`.as(
'tourPatientName',
),
sql<string>`concat("Patient".name, ' ', "Patient".surname)`.as(
'patientName',
),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select(eb.fn.countAll<number>().as('count'))
.as('tourCount'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('ordinanceType')
.limit(1)
.as('ordinanceType'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('Tour.startDate')
.where('Tour.startDate', '>=', sql<Date>`now()`)
.orderBy('Tour.startDate', 'asc')
.limit(1)
.as('nextStartDate'),
])
.limit(5)
.execute();
return rows.map(
(row): TicketListItemDto => ({
id: row.id,
ordinanceType: row.ordinanceType,
patientName:
`${row.patientName}`.length > `${row.tourPatientName}`.length
? `${row.patientName}`
: row.tourPatientName,
stage: row.stage,
nextStart: row.nextStartDate,
tourCount: row.tourCount,
approvalRequirement: row.approvalRequirement,
approvalStatus: row.approvalStatus,
isOverdue: row.approvalRequestedAt
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
: false,
errorCount: 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}),
);
}
async getTicketsForLane(
stage: string,
page: number,
limit: number = 10,
): Promise<PaginatedResult<TicketListItemDto>> {
const offset = (page - 1) * limit;
const baseQuery = this.db
.selectFrom('Ticket')
.where('Ticket.stage', '=', stage);
const countResult = await baseQuery
.select((eb) => eb.fn.countAll<string>().as('total'))
.executeTakeFirst();
const total = Number(countResult?.total ?? 0);
const rows = await baseQuery
.select((eb) => [
'Ticket.id',
'Ticket.stage',
'Ticket.approvalRequirement',
'Ticket.approvalStatus',
'Ticket.approvalRequestedAt',
'Ticket.createdAt',
'Ticket.updatedAt',
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select(eb.fn.countAll<number>().as('count'))
.as('tourCount'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('patientName')
.limit(1)
.as('patientName'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('patientSurname')
.limit(1)
.as('patientSurname'),
eb
.selectFrom('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.select('ordinanceType')
.limit(1)
.as('ordinanceType'),
eb
.selectFrom('Tour')
.select('Tour.startDate')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.where('Tour.startDate', '>=', sql<Date>`now()`)
.orderBy('Tour.startDate', 'asc')
.limit(1)
.as('nextStartDate'),
])
.limit(limit)
.offset(offset)
.orderBy('createdAt', 'desc')
.execute();
const data = rows.map((row) => ({
id: row.id,
ordinanceType: row.ordinanceType,
patientName: `${row.patientName} ${row.patientSurname}`,
stage: row.stage,
nextStart: row.nextStartDate,
tourCount: row.tourCount,
approvalRequirement: row.approvalRequirement,
approvalStatus: row.approvalStatus,
isOverdue: row.approvalRequestedAt
? differenceInDays(new Date(), row.approvalRequestedAt) >= 3
: false,
errorCount: 0,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
}
@@ -1,5 +1,5 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
import { Queue } from 'bullmq';
@@ -19,12 +19,14 @@ import {
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
import {
PaginatedTicketObjectType,
TicketObjectType,
TicketStateKeyFiguresObjectType,
TicketStateMetaObjectType,
} from './ticket.object-type';
import { TicketRepository } from './ticket.repository';
import { TicketRepository as LegacyTicketRepository } from './ticket.repository';
import { TourFilesService } from '../feat-business-objects/entities/tour-files/tour-files.service';
import { TICKET_REPOSITORY, TicketRepository } from '../feat-tickets/domain/ticket.repository.interface';
import { TicketId } from '../feat-tickets/domain/ticket.entity';
@Injectable()
export class TicketSystemService {
public readonly pubSub = new PubSub();
@@ -37,7 +39,8 @@ export class TicketSystemService {
}
constructor(
private readonly repository: TicketRepository,
private readonly repository: LegacyTicketRepository,
@Inject(TICKET_REPOSITORY) private readonly dddRepo: TicketRepository,
private readonly ticketService: TicketService,
private readonly tourService: TourService,
private readonly tourFileService: TourFilesService,
@@ -67,10 +70,6 @@ export class TicketSystemService {
return true;
}
findById(id: string): Promise<Ticket> {
return this.ticketService.findById(id);
}
findAll(): Promise<Ticket[]> {
return this.ticketService.findAll() as Promise<Ticket[]>;
}
@@ -97,7 +96,7 @@ export class TicketSystemService {
const nextPage = skip + take < total ? skip + take : skip;
return {
nodes: tickets,
nodes: [], // tickets,
prevCursor: encodeCursor(prevPage),
endCursor: encodeCursor(nextPage),
hasNextPage: nextPage !== skip,
@@ -29,6 +29,24 @@ export class DocumentInfoObjectType {
@ObjectType('Ticket')
export class TicketObjectType implements Ticket {
lastStageBeforeClosed: string;
approvalPreInquiryMessageId: string;
approvalFullRequestMessageId: string;
approvalRecipient: string;
approvalIsOverriden: boolean;
approvalStatus: string;
approvalLastInteractionAt: Date;
approvalReminderCount: number;
hasPhysicalTransportDocument: boolean;
hasDigitalTransportDocument: boolean;
patientId: string;
updatedAt: Date;
stage: string;
approvalRequirement: string;
approvalRequestedAt: Date;
approvalIsGranted: boolean;
hasTransportDocument: boolean;
@Field()
id: string;
@Field(() => TicketValidationState)
@@ -1,31 +1,179 @@
import { InjectQueue } from '@nestjs/bullmq';
import {
GetBoardQueryDto,
PaginatedResult,
TicketApprovalViewDto,
TicketDetailDto,
TicketListItemDto,
} from '@avicenna/shared-dtos';
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Logger,
NotFoundException,
Param,
Post,
Query,
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';
import { MarkAsApprovedUseCase } from '../feat-tickets/application/mark-as-approved.usecase';
import { OverrideApprovalRequirementUseCase } from '../feat-tickets/application/override-approval-requirement.usecase';
import { ReceivePhysicalTransportDocumentUseCase } from '../feat-tickets/application/receive-physical-transport-document.usecase';
import { RequestTicketApprovalUseCase } from '../feat-tickets/application/request-ticket-approval.usecase';
import { SendApprovalReminderUseCase } from '../feat-tickets/application/send-approval-reminder.usecase';
import { UploadTransportDocumentUseCase } from '../feat-tickets/application/upload-transport-document.usecase';
import { TicketId } from '../feat-tickets/domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../feat-tickets/domain/ticket.repository.interface';
import { TicketReadMapper } from './mappers/ticket-read.mapper';
import { TicketQueryService } from './queries/ticket-query.service';
import { RegisterExternalApprovalRequestUseCase } from '../feat-tickets/application/register-external-approval-request.usecase';
@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,
private readonly queryService: TicketQueryService,
private readonly ticketReadMapper: TicketReadMapper,
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
private readonly uploadUseCase: UploadTransportDocumentUseCase,
private readonly overrideUseCase: OverrideApprovalRequirementUseCase,
private readonly requestApprovalUseCase: RequestTicketApprovalUseCase,
private readonly sendReminderUseCase: SendApprovalReminderUseCase,
private readonly approveUseCase: MarkAsApprovedUseCase,
private readonly receiveUseCase: ReceivePhysicalTransportDocumentUseCase,
private readonly registerUseCase: RegisterExternalApprovalRequestUseCase,
) {}
@Get('search')
async searchTickets(
@Query('q') searchTerm: string,
): Promise<TicketListItemDto[]> {
if (!searchTerm) {
throw new BadRequestException("no 'q' provided");
}
return this.queryService.searchTicket(searchTerm);
}
@Get()
async getBoard(
@Query() query: GetBoardQueryDto,
): Promise<PaginatedResult<TicketListItemDto>> {
this.logger.log(
`fetching tickets board for stage ${query.stage} at page ${query.page}`,
);
return this.queryService.getTicketsForLane(
query.stage,
query.page,
query.limit,
);
}
@Get(':id')
async getTicket(@Param('id') id: string): Promise<TicketDetailDto> {
this.logger.log(`fetch ticket ${id}`);
const ticketId = new TicketId(id);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${id} not found`);
}
return this.ticketReadMapper.toDetailDto(ticket);
}
@Get(':id/approval')
async getTicketApprovalView(
@Param('id') id: string,
): Promise<TicketApprovalViewDto> {
this.logger.log(`fetch ticket ${id}`);
const ticketId = new TicketId(id);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${id} not found`);
}
return this.ticketReadMapper.toApprovalView(ticket);
}
@Post(':id/approval/request')
@HttpCode(HttpStatus.NO_CONTENT)
async requestApproval(
@Param('id') id: string,
@Body('recipient') recipient: string,
) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
if (!recipient) {
throw new BadRequestException(`no recipient provided`);
}
await this.requestApprovalUseCase.execute(id, recipient);
}
@Post(':id/approval/register-request')
@HttpCode(HttpStatus.NO_CONTENT)
async registerExternalApprovalRequest(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.registerUseCase.execute(id);
}
@Post(':id/approval/remind')
@HttpCode(HttpStatus.NO_CONTENT)
async sendReminder(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.sendReminderUseCase.execute(id);
}
@Post(':id/approval/approve')
@HttpCode(HttpStatus.NO_CONTENT)
async approve(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.approveUseCase.execute(id);
}
@Post(':id/approval/override')
@HttpCode(HttpStatus.NO_CONTENT)
async overrideApproval(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.overrideUseCase.execute(id);
}
@Post(':id/transport-document/receive')
@HttpCode(HttpStatus.NO_CONTENT)
async receiveTD(@Param('id') id: string) {
if (!id) {
throw new BadRequestException(`no id provided`);
}
await this.receiveUseCase.execute(id);
}
@Post(':ticketId/files')
@UseInterceptors(FileInterceptor('file'))
@HttpCode(HttpStatus.ACCEPTED)
@@ -33,30 +181,12 @@ export class TicketsController {
@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!'}
if (!file) {
throw new BadRequestException('No file provided');
}
const storage = file.path;
const tours = await this.tourService.findByTicketId(ticketId);
await this.uploadUseCase.execute(ticketId, file);
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' };
return { message: 'File accepted and processing' };
}
}
@@ -25,7 +25,7 @@ export class TicketsResolver {
@Query(() => TicketObjectType)
ticket(@Args('id') id: string) {
return this.ticketsService.findById(id);
// return this.ticketsService.findById(id);
}
@Query(() => PaginatedTicketObjectType)
@@ -14,6 +14,7 @@ import { IsGermanZipDetector } from './anomaly-detectors/is-german-zip.detector'
import { EmployeeStillWorkingDetector } from './anomaly-detectors/employee-still-working.detector';
import { IsAttendanceBeforeWorktimeDetector } from './anomaly-detectors/is-attendance-before-worktime.detector';
import { IsWorkTimePlannedDetector } from './anomaly-detectors/is-work-time-planned.detector';
import { KtwTourNotWithKtwCarDetector } from './anomaly-detectors/ktw-tour-with-ktw-car.detector';
@Injectable()
export class AnomaliesService {
@@ -35,6 +36,7 @@ export class AnomaliesService {
new EmployeeStillWorkingDetector(this.anomalyDetectorService),
new IsAttendanceBeforeWorktimeDetector(this.anomalyDetectorService),
// new IsWorkTimePlannedDetector(this.anomalyDetectorService)
new KtwTourNotWithKtwCarDetector(this.anomalyDetectorService)
];
}
@@ -34,6 +34,22 @@ export class AnomalyDetectorRepository {
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async ktwToursNotOnKtwCars(interval: Interval): Promise<Tour[]> {
return await this.db
.selectFrom('Tour')
.selectAll()
.where((eb) =>
eb.and([
eb('Tour.ordinanceType', 'ilike', '%ktw%'),
eb('Tour.carName', 'not ilike', '%ktw%'),
eb('Tour.startDate', '>=', new Date(interval.start)),
eb('Tour.startDate', '<=', new Date(interval.end)),
onlyRealTours(eb),
]),
)
.execute();
}
async toursWithoutGermanZipInInterval(interval: Interval): Promise<Tour[]> {
return await this.db
.selectFrom('Tour')
@@ -37,6 +37,10 @@ export class AnomalyDetectorService {
private readonly anomalyRepository: AnomalyDetectorRepository,
) {}
public async findKtwToursNotOnKtwCars(interval: Interval): Promise<Tour[]> {
return this.anomalyRepository.ktwToursNotOnKtwCars(interval)
}
public findToursWithoutGermanZipInInterval(
interval: Interval,
): Promise<Tour[]> {
@@ -16,56 +16,54 @@ export class DriverCertificateDetector extends AnomalyDetector {
);
const toursInRange = await this.dataService.findToursInInterval(inInterval);
const anomalies = await Promise.all(
toursInRange
.filter(({ driverId }) => !!driverId)
.map(
async ({
id: tourId,
driverId,
startDate,
driverName,
carName,
operationId,
}) => {
try {
const driverEmployee =
await this.dataService.findEmployeeById(driverId);
if (!driverEmployee) {
try {
const anomalies = await Promise.all(
toursInRange
.filter((tour) => !!tour?.driverId && !!tour?.startDate)
.map(
async ({
id: tourId,
driverId,
startDate,
driverName,
carName,
operationId,
}) => {
try {
const driverEmployee =
await this.dataService.findEmployeeById(driverId);
if (!driverEmployee) {
return;
}
if (
!this.isPersonTransportCertificateValidAtStartOfTour(
driverEmployee.personTransportCertificate,
startDate,
)
) {
this.addAssociatedEntity('employee', driverEmployee.id);
this.addAssociatedEntity('tour', tourId);
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
carName,
driverName,
new Date(startDate),
);
}
} catch (error) {
this.logger.error(error);
return;
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
carName,
driverName ?? '<Fahrer gelöscht>',
new Date(startDate),
);
}
if (
!this.isPersonTransportCertificateValidAtStartOfTour(
driverEmployee.personTransportCertificate,
startDate,
)
) {
this.addAssociatedEntity('employee', driverEmployee.id);
this.addAssociatedEntity('tour', tourId);
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
carName,
driverName,
new Date(startDate),
);
}
} catch (error) {
this.logger.error(error);
return;
}
},
),
);
return anomalies.filter((anomaly) => !!anomaly);
},
),
);
return anomalies.filter((anomaly) => !!anomaly);
} catch (error) {
this.logger.error(error)
return
}
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
@@ -45,7 +45,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
const tour = await this.dataService.findTourByOperationId(operationId);
const employee = await this.dataService.findEmployeeById(employeeId);
if(!tour?.startDate) {
this.logger.error(`tour ${tour?.operationId} has no startDate`)
this.logger.error(`tour ${tour?.operationId ?? operationId} has no startDate`)
}
const startOfTour = this.getStartOfTour(
new Date(tour?.startDate),
@@ -0,0 +1,102 @@
import { Logger } from '@nestjs/common';
import { AnomalyDetector } from './anomaly-detector.class';
import { Prisma, Anomaly } from '@prisma/client';
import { format, Interval } from 'date-fns';
import { AnomalyTypes } from 'src/modules/feat-business-objects/entities/anomaly/anomaly-types.enum';
import { toZonedTime } from 'date-fns-tz';
export class KtwTourNotWithKtwCarDetector extends AnomalyDetector {
private readonly logger = new Logger(KtwTourNotWithKtwCarDetector.name);
readonly type = AnomalyTypes.KtwTourNotWithKtwCar;
async detect(interval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
this.logger.log(
`start '${KtwTourNotWithKtwCarDetector.name}' with interval ${format(interval.start, 'yyyy-MM-dd HH:mm')} - ${format(interval.end, 'yyyy-MM-dd HH:mm')}`,
);
const tours = await this.dataService.findKtwToursNotOnKtwCars(interval);
return await Promise.all(
tours.map((tour) =>
this.createAnomaly(
`${tour.operationId}-${this.type}`,
tour.id,
tour.operationId,
tour.startDate,
),
),
);
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
const [operationId] = anomaly.id.split('-');
try {
const tour = await this.dataService.findTourByOperationId(operationId);
const isKtwTour = tour?.ordinanceType.toLowerCase().indexOf('ktw') > -1;
const isKtwResource = tour?.carName.toLowerCase().indexOf('ktw') > -1;
if (!isKtwTour || isKtwResource) {
return {
...anomaly,
resolvedAt: new Date(),
};
} else {
return {
...anomaly,
};
}
} catch (error) {
this.logger.error(error, { anomalyId: anomaly.id });
// Im Fehlerfall geben wir die Anomalie unverändert zurück, um Datenverlust zu vermeiden
return anomaly;
}
}
createDescription({ operationId }: { operationId: string }): string {
return `${operationId}: KTW-Tour auf falschem Fahrzeugtyp`;
}
createSolution({
tourId,
}: {
tourId: string;
}): Prisma.SolutionTargetCreateInput[] {
return [
{
name: 'DispoLive',
steps: {
create: [
{
description: `Öffnen Sie den <a class="dispolive-btn" href="https://avicenna.dispolive.de/fahrbericht/${tourId}" target="_blank">Fahrbericht</a> und weisen Sie der Tour ein Fahrzeug des Typs <strong>KTW</strong> zu.`,
},
],
},
},
];
}
private createAnomaly(
anomalyId: string,
tourId: string,
operationId: string,
startDate: Date,
): Prisma.AnomalyCreateInput {
return {
...this.getAnomalySkeleton(),
id: anomalyId,
type: this.type,
groupKey: `${format(
new Date(startDate),
'yyyy-MM-dd-HH-mm',
)}-${operationId}`,
groupDescription: `${operationId} - ${format(
toZonedTime(new Date(startDate), 'Europe/Berlin'),
'HH:mm dd.MM.yy',
)}`,
solution: {
create: this.createSolution({ tourId }),
},
description: this.createDescription({ operationId }),
};
}
}
@@ -6,6 +6,7 @@ export enum AnomalyTypes {
IsAttendanceBeforeWorktime = 'IsAttendanceBeforeWorktime',
IsGermanZip = 'IsGermanZip',
IsWorktimePlanned = 'IsWorktimePlanned',
KtwTourNotWithKtwCar = 'KtwTourWithKtwCar',
OneTourAtATime = 'OneTourAtATime',
WorkingEmployeeNotInDistributionSystem = 'WorkingEmployeeNotInDistributionSystem',
}
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Prisma, TicketValidationState, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns';
import { ExpressionBuilder, ExpressionWrapper, Kysely, SqlBool } from 'kysely';
import { ExpressionBuilder, ExpressionWrapper, Kysely, sql, 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';
@@ -26,8 +26,8 @@ function onlyRealToursKysely(eb: TourEb) {
eb('deletedAt', 'is', null),
eb.or([
eb('patientId', 'is', null),
eb('patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
eb('Tour.patientId', 'is', null),
eb('Tour.patientId', 'not in', [PAUSE_PATIENT_ID, FEIERABEND_PATIENT_ID]),
]),
]);
}
@@ -77,6 +77,36 @@ export class TourRepository {
.orderBy('Tour.startDate asc')
.execute();
}
// tour.repository.ts
async findForCarInInterval(
carName: string,
interval: Interval,
): Promise<Tour[]> {
return await this.db
.selectFrom('Tour')
.selectAll('Tour')
.where((eb) =>
eb.and([
eb('Tour.carName', 'ilike', `%${carName}%`),
eb('Tour.startDate', '>=', new Date(interval.start)),
eb('Tour.startDate', '<', new Date(interval.end)),
onlyRealToursKysely(eb),
]),
)
.orderBy('Tour.startDate', 'asc')
.execute();
}
async findWithoutTicket(): Promise<Tour[]> {
return this.db
.selectFrom('Tour')
.selectAll()
.where('ticketId', 'is', null)
.where((eb) => onlyRealToursKysely(eb))
.limit(1000)
.execute();
}
async findUpdatedAtBetween(
firstId: string,
@@ -297,26 +327,39 @@ export class TourRepository {
// --------------------- Accounting --------------------- //
async findByAccountingState(
state: AccountingState,
filter: TourAccountingFilterDto,
take?: number,
skip?: number,
): Promise<{ tours: Tour[]; total: number }> {
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
return await this.db.transaction().execute(async (trx) => {
const tours = await trx
const tours = (await trx
.selectFrom('Tour')
.selectAll()
// .selectAll('Tour')
.select([
'Tour.id',
'Tour.operationId',
'Tour.startDate',
'Tour.revenue',
'Tour.revenueDispoLive',
'Tour.optimizationInMinutes',
'Tour.ordinanceType',
])
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.select(['Ticket.stage', 'Ticket.patientId as ticketPatientId'])
.where((eb) =>
eb.and([
this.getFilterForAccountingState(eb, state),
onlyRealToursKysely(eb),
]),
)
.orderBy(sql`abs("Tour"."optimizationInMinutes")`, 'desc')
.orderBy('Tour.revenueDeviation desc')
.offset(skip)
.limit(take)
.execute();
.execute()) as unknown as (Tour & { stage: string })[];
const total = await trx
.selectFrom('Tour')
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.where((eb) =>
eb.and([
this.getFilterForAccountingState(eb, state),
@@ -393,33 +436,11 @@ export class TourRepository {
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 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 eb.and([eb('Tour.check', '=', 1)]);
@@ -4,6 +4,8 @@ import { Interval } from 'date-fns';
import { AccountingState } from 'src/modules/app-accounting/accounting-state';
import { TourRepository } from './tour.repository';
import { TourAccountingFilterDto } from './tours.filter';
import { OptimizationCarDto } from '@avicenna/shared-dtos';
import { generateIntervalForTimeRange } from 'src/utils';
@Injectable()
export class TourService {
@@ -16,6 +18,10 @@ export class TourService {
return this.repository.findInInterval(interval, ordinanceTypes);
}
async findWithoutTicket(): Promise<Tour[]> {
return this.repository.findWithoutTicket();
}
async findUpdatedAtBetween(
firstId: string,
lastId: string,
@@ -35,6 +41,22 @@ export class TourService {
);
}
async getOptimizationToursForCar(
carName: string,
day: Date,
): Promise<Tour[]> {
const operationDayInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
day,
);
return await this.repository.findForCarInInterval(
carName,
operationDayInterval,
);
}
async findById(tourId: string): Promise<Tour> {
return this.repository.findById(tourId);
}
@@ -56,7 +78,7 @@ export class TourService {
}
async findTragehilfeTour(tour: Tour): Promise<Tour | null> {
return this.repository.findTragehilfeTour(tour)
return this.repository.findTragehilfeTour(tour);
}
async findToursWithTicket(interval: Interval): Promise<Tour[]> {
@@ -71,7 +93,6 @@ export class TourService {
return this.repository.createTour(tourInput);
}
async updateTour(
id: string,
tourInput: Prisma.TourUpdateInput,
@@ -85,11 +106,10 @@ export class TourService {
async findByAccountingState(
state: AccountingState,
filter: TourAccountingFilterDto,
take?: number,
skip?: number,
): Promise<{ tours: Tour[]; total: number }> {
return this.repository.findByAccountingState(state, filter, take, skip);
): Promise<{ tours: (Tour & { stage: string })[]; total: number }> {
return this.repository.findByAccountingState(state, take, skip);
}
async calculateAccountingStateMeta(state: AccountingState): Promise<{
@@ -269,6 +269,7 @@ export class DispoLiveExtractorsService {
importPersId,
qualifikation,
} = employee;
const quali = qualifikation as string | { _id: string };
return {
id: _id,
pScheinAblaufDatum,
@@ -277,11 +278,11 @@ export class DispoLiveExtractorsService {
persId,
importPersId,
qualifikation:
!!qualifikation && typeof qualifikation === 'object'
? //@ts-expect-error ts is wrong here
qualifikation?._id
: (qualifikation ?? undefined),
};
(quali && typeof quali === 'object' && '_id' in quali)
?
quali?._id
: (quali ?? undefined),
} as EmployeeDispoLiveRaw;
}),
),
);
@@ -378,6 +379,7 @@ export class DispoLiveExtractorsService {
birthday,
kkId,
krankenkasse,
stammVersNr,
pflegestufe,
behinderung1,
behinderung2,
@@ -392,6 +394,7 @@ export class DispoLiveExtractorsService {
birthday,
kkId,
krankenkasse,
stammVersNr,
pflegestufe: careDegrees.find(({ id }) => id === pflegestufe)
?.careDegree,
behinderung1: disabilityMarks.find(
@@ -45,12 +45,12 @@ export class DyflexisUserAccessExtractorsService {
loadData(): Observable<any> {
return combineLatest([
// this.extractWorkdays(),
this.extractWorkdays(),
// this.extractSicknessDays({
// start: new Date('2025-01-01T00:00:00.000Z'),
// end: new Date('2025-12-31T23:59:59.999Z'),
// }),
this.extractHolidays(new Date('2025-01-01T00:00:00.000Z')),
// this.extractHolidays(new Date('2025-01-01T00:00:00.000Z')),
]);
}
@@ -34,6 +34,7 @@ export class TourExtractor {
itName,
kkId,
krankenkasse,
stammVersNr,
patId,
patName,
patSurname,
@@ -104,6 +105,7 @@ export class TourExtractor {
itName: itName ? `${itName}` : undefined,
kkId: kkId ? `${kkId}` : undefined,
krankenkasse: krankenkasse ? `${krankenkasse}` : undefined,
stammVersNr: stammVersNr ? `${stammVersNr}` : undefined,
patId: patId ? `${patId}` : undefined,
patName: patName ? `${patName}` : undefined,
patSurname: patSurname ? `${patSurname}` : undefined,
@@ -31,6 +31,7 @@ export interface TourRaw {
itName?: string;
kkId?: string;
krankenkasse?: string;
stammVersNr?: string;
patId?: string;
patName?: string;
patSurname?: string;
@@ -95,6 +96,7 @@ export interface PatientRaw {
city?: string;
kkId?: string;
krankenkasse?: string;
stammVersNr?: string;
pflegestufe?: string;
behinderung1?: string;
behinderung2?: string;
@@ -1,7 +1,7 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { lastValueFrom } from 'rxjs';
import { lastValueFrom, of } 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';
@@ -54,6 +54,7 @@ export class PatientLoader {
birthday,
healthinsurance,
healthinsuranceId,
insuranceNo,
careDegree,
disabilityMark1,
disabilityMark2,
@@ -69,6 +70,7 @@ export class PatientLoader {
birthday,
healthinsurance,
healthinsuranceId,
insuranceNo,
careDegree,
disabilityMark1,
disabilityMark2,
@@ -30,7 +30,10 @@ export class TourLoader {
);
if (finalTour) {
updatedTours.push(
this.tourStagingToFinal(stagingTour, finalTour.ticketId),
this.tourStagingToFinal(stagingTour, {
ticketId: finalTour?.ticketId,
optimizationInMinutes: finalTour?.optimizationInMinutes,
}),
);
} else {
newTours.push(this.tourStagingToFinal(stagingTour));
@@ -44,7 +47,10 @@ export class TourLoader {
};
}
private static tourStagingToFinal(tour: TourStaging, ticketId?: string) {
private static tourStagingToFinal(
tour: TourStaging,
keepData?: { ticketId: string; optimizationInMinutes: number },
) {
const {
id,
done,
@@ -63,6 +69,7 @@ export class TourLoader {
patientCity,
healthInsurance,
healthInsuranceNumber,
insuranceNo,
type,
category,
transportType,
@@ -118,6 +125,7 @@ export class TourLoader {
patientCity,
healthInsurance,
healthInsuranceNumber,
insuranceNo,
type,
category,
transportType,
@@ -153,7 +161,10 @@ export class TourLoader {
billNumber,
createdAt,
updatedAt,
...(ticketId ? { ticketId } : {}),
...(keepData?.ticketId ? { ticketId: keepData.ticketId } : {}),
...(keepData?.optimizationInMinutes
? { optimizationInMinutes: keepData.optimizationInMinutes }
: {}),
};
}
}
@@ -18,6 +18,7 @@ export interface TourPreStaging {
patientCity?: string;
healthInsurance?: string;
healthInsuranceNumber?: string;
insuranceNo?: string;
type?: string;
category?: string;
@@ -75,6 +76,7 @@ export interface PatientPreStaging {
birthday?: Date;
healthinsuranceId?: string;
healthinsurance?: string;
insuranceNo?: string;
careDegree?: string;
disabilityMark1?: string;
disabilityMark2?: string;
@@ -31,6 +31,7 @@ export class PatientTransformer {
birthday,
kkId,
krankenkasse,
stammVersNr,
pflegestufe,
behinderung1,
behinderung2,
@@ -46,6 +47,7 @@ export class PatientTransformer {
birthday: isValidDate(new Date(birthday)) ? birthday : undefined,
healthinsurance: krankenkasse,
healthinsuranceId: kkId,
insuranceNo: stammVersNr,
careDegree: pflegestufe,
disabilityMark1: behinderung1,
disabilityMark2: behinderung2,
@@ -85,6 +85,7 @@ export class TourTransformer {
patCity,
krankenkasse,
kkId,
stammVersNr,
type,
serienKategorName,
transportKrz,
@@ -146,6 +147,7 @@ export class TourTransformer {
patientCity: patCity,
healthInsurance: krankenkasse,
healthInsuranceNumber: kkId,
insuranceNo: stammVersNr,
category: serienKategorName,
transportType: transportKrz,
ordinanceType: verordnungsName,
@@ -0,0 +1,18 @@
import { KpiInfoDto } from '@avicenna/shared-dtos';
import { BadRequestException, Controller, Get, Query } from '@nestjs/common';
import { KpiService } from './kpi.service';
@Controller('api/kpis')
export class KpiController {
constructor(private readonly kpiService: KpiService) {}
@Get('manager')
managerKpi(
@Query('ordinanceType') ordinanceType: string,
): Promise<KpiInfoDto> {
if(!ordinanceType) {
throw new BadRequestException('no ordinanceType provided')
}
return this.kpiService.managerKpi(ordinanceType);
}
}
@@ -4,9 +4,11 @@ import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-busines
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
import { KpiResolver } from './kpi.resolver';
import { KpiService } from './kpi.service';
import { KpiController } from './ kpi.controller';
@Module({
imports: [PrismaModule, FeatBusinessObjectsModule, FeatCalculationModule],
controllers: [KpiController],
providers: [KpiService, KpiResolver],
exports: [KpiService],
})
@@ -0,0 +1,51 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { TICKET_REPOSITORY, TicketRepository } from '../domain/ticket.repository.interface';
import { TicketId } from '../domain/ticket.entity';
import { TOUR_PROVIDER, TourProvider } from '../domain/ports/tour-provider.interface';
import { PATIENT_PROVIDER, PatientProvider } from '../domain/ports/patient-provider.interface';
@Injectable()
export class AssignTourToTicketUseCase {
private readonly logger = new Logger(AssignTourToTicketUseCase.name)
constructor(
@Inject(TICKET_REPOSITORY) private ticketRepo: TicketRepository,
@Inject(TOUR_PROVIDER) private tourAdapter: TourProvider,
@Inject(PATIENT_PROVIDER) private readonly patientProvider: PatientProvider
) {}
async execute(ticketIdRaw: string, tourIdRaw: string): Promise<void> {
this.logger.log(`assigning Tour ${tourIdRaw} to Ticket ${ticketIdRaw}`)
const ticketId = new TicketId(ticketIdRaw)
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket with ID ${ticketIdRaw} not found`)
}
const tour = await this.tourAdapter.findTourById(tourIdRaw)
if(!tour) {
throw new NotFoundException(`Tour with ID ${tourIdRaw} not found`)
}
if(tour.patientId !== ticket.patientId.value && tour.patientId) {
const patient = await this.patientProvider.findPatientById(tour.patientId);
if(patient) {
ticket.updatePatientData(patient)
}
}
// if(ticket.patientId.value !== tour.patientId) {
// this.logger.warn('patient mismatch', {ticketPatientId: ticket.patientId.value, tourPatientId: tour.patientId})
// throw new BadRequestException(`Patient mismatch between Ticket and Tour`)
// }
try {
ticket.assignTour(tour)
} catch (error) {
throw new BadRequestException(error.message)
}
await this.ticketRepo.save(ticket)
}
}
@@ -0,0 +1,20 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
@Injectable()
export class CloseInvoicedTickets {
private readonly logger = new Logger(CloseInvoicedTickets.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(): Promise<void> {
this.logger.log(`archiving invoiced tickets`);
await this.ticketRepo.closeSettledTickets();
}
}
@@ -0,0 +1,70 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import {
PATIENT_PROVIDER,
PatientProvider,
} from '../domain/ports/patient-provider.interface';
import {
TOUR_PROVIDER,
TourProvider,
} from '../domain/ports/tour-provider.interface';
import { Ticket, TicketId } from '../domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
export interface CreateTicketCommand {
patientId: string;
initialTourId?: string;
}
@Injectable()
export class CreateTicketUseCase {
private readonly logger = new Logger(CreateTicketUseCase.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
@Inject(TOUR_PROVIDER) private readonly tourProvider: TourProvider,
@Inject(PATIENT_PROVIDER) private readonly patientProvider: PatientProvider,
) {}
async execute(command: CreateTicketCommand): Promise<string> {
const newTicketId = new TicketId(uuidv4());
const ticket = Ticket.create(newTicketId, command.patientId);
if (command.patientId) {
const patient = await this.patientProvider.findPatientById(
command.patientId,
);
if (patient) {
ticket.updatePatientData(patient);
}
}
if (command.initialTourId) {
const tour = await this.tourProvider.findTourById(command.initialTourId);
if (!tour)
throw new NotFoundException(
`initial tour ${command.initialTourId} not found`,
);
if (tour.patientId !== command.patientId) {
throw new BadRequestException(`Patient mismatch`);
}
ticket.assignTour(tour);
}
await this.ticketRepo.save(ticket);
return newTicketId.value;
}
}
@@ -0,0 +1,24 @@
import { Inject, Injectable, Logger, NotFoundException, NotImplementedException } from "@nestjs/common";
import { TicketId } from "../domain/ticket.entity";
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
@Injectable()
export class MarkAsApprovedUseCase {
private readonly logger = new Logger(MarkAsApprovedUseCase.name)
constructor(@Inject(TICKET_REPOSITORY) private ticketRepo: TicketRepository) {}
async execute(ticketIdRaw: string): Promise<void> {
this.logger.log(`approving Ticket ${ticketIdRaw}`)
const ticketId = new TicketId(ticketIdRaw)
const ticket = await this.ticketRepo.findById(ticketId);
if(!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`)
}
ticket.grantApproval()
await this.ticketRepo.save(ticket)
}
}
@@ -0,0 +1,56 @@
import {
Inject,
Injectable,
Logger,
NotFoundException,
NotImplementedException,
} from '@nestjs/common';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
import { ApprovalRequirement } from '@avicenna/shared-dtos';
import { TicketId } from '../domain/ticket.entity';
@Injectable()
export class OverrideApprovalRequirementUseCase {
private readonly logger = new Logger(OverrideApprovalRequirementUseCase.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(ticketIdRaw: string) {
this.logger.log(
`overriding approval requirement for Ticket ${ticketIdRaw}`,
);
const ticketId = new TicketId(ticketIdRaw);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
}
const newRequirement = this.determineNewRequirement(
ticket.approval.requirement,
);
ticket.overrideApprovalRequirement(newRequirement);
console.log(ticket.approval.requirement, newRequirement)
await this.ticketRepo.save(ticket)
}
private determineNewRequirement(
requirement: ApprovalRequirement,
): ApprovalRequirement {
if (
requirement === ApprovalRequirement.UNKOWN ||
requirement === ApprovalRequirement.NOT_REQUIRED
) {
return ApprovalRequirement.REQUIRED;
}
return ApprovalRequirement.NOT_REQUIRED;
}
}
@@ -0,0 +1,55 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { LegacyTourAdapter } from '../infrastructure/legacy-tour.adapter';
import { TicketMatchingService } from '../domain/services/ticket-matching.service';
import { AssignTourToTicketUseCase } from './assign-tour.usecase';
import { TourCandidate } from '../domain/interfaces/tour-candidate.interface';
import { CreateTicketUseCase } from './create-ticket.usecase';
import { TOUR_PROVIDER } from '../domain/ports/tour-provider.interface';
@Injectable()
export class ProcessUnticketedToursUseCase {
private readonly logger = new Logger(ProcessUnticketedToursUseCase.name);
constructor(
@Inject(TOUR_PROVIDER) private readonly tourAdapter: LegacyTourAdapter,
private readonly matcher: TicketMatchingService,
private createTicketUseCase: CreateTicketUseCase,
private assignUseCase: AssignTourToTicketUseCase,
) {}
async execute(): Promise<void> {
const orphanedTours = await this.tourAdapter.findUnticketedTours();
this.logger.log(`processing ${orphanedTours.length} orphaned tours...`);
for (const tour of orphanedTours) {
try {
await this.processSingleTour(tour);
} catch (error) {
this.logger.error(`error processing tour ${tour.id.value}:`, error);
this.logger.error(error)
}
}
this.logger.log(`count patientids`, {
id_count: orphanedTours.filter(({patientId}) => !!patientId).length,
tours: orphanedTours.length
})
}
private async processSingleTour(tour: TourCandidate): Promise<void> {
const match = await this.matcher.findMatchingTicket(tour);
if (match) {
await this.assignUseCase.execute(match.id.value, tour.id.value);
this.logger.log(
`Tour ${tour.id.value} assigned to existing Ticket ${match.id.value}`,
);
} else {
await this.createTicketUseCase.execute({
initialTourId: tour.id.value,
patientId: tour.patientId,
});
this.logger.log(`created new Ticket for Tour ${tour.id.value}`);
}
}
}
@@ -0,0 +1,30 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
import { TicketId } from '../domain/ticket.entity';
@Injectable()
export class ReceivePhysicalTransportDocumentUseCase {
private readonly logger = new Logger(
ReceivePhysicalTransportDocumentUseCase.name,
);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(ticketIdRaw: string): Promise<void> {
this.logger.log(
`receiving physical transport document for Ticket ${ticketIdRaw}`,
);
const ticket = await this.ticketRepo.findById(new TicketId(ticketIdRaw));
if (!ticket) throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
ticket.receivePhysicalDocument();
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,52 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import { Ticket } from '../domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
@Injectable()
export class RefreshTicketDataUseCase {
private readonly logger = new Logger(RefreshTicketDataUseCase.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly repo: TicketRepository,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async execute() {
this.logger.log(`starting Ticket refresh cycle ...`);
for await (const batch of this.repo.streamAllActive(50)) {
await Promise.all(batch.map((ticket) => this.processTicket(ticket)));
}
}
private async processTicket(ticket: Ticket) {
const savedStage = await this.getCurrentStage(ticket.id.value);
if (savedStage !== ticket.currentStage) {
this.logger.log(
`Ticket ${ticket.id.value} stage changed from ${savedStage} to ${ticket.currentStage}`,
);
await this.repo.save(ticket);
}
}
private async getCurrentStage(ticketId: string): Promise<string> {
const currentStage = await this.db
.selectFrom('Ticket')
.select('Ticket.stage')
.where('Ticket.id', '=', ticketId)
.executeTakeFirst();
if (!currentStage?.stage) {
throw new NotFoundException(`Ticket ${ticketId} not found`);
}
return currentStage.stage;
}
}
@@ -0,0 +1,34 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { TicketId } from '../domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
@Injectable()
export class RegisterExternalApprovalRequestUseCase {
private readonly logger = new Logger(
RegisterExternalApprovalRequestUseCase.name,
);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(ticketIdRaw: string) {
this.logger.log(
`Registering external approval request for Ticket ${ticketIdRaw}`,
);
const ticketId = new TicketId(ticketIdRaw);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
}
ticket.registerExternalRequest();
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,21 @@
import { Injectable, Logger, Inject, NotFoundException } from "@nestjs/common";
import { TicketId } from "../domain/ticket.entity";
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
@Injectable()
export class RegisterSentEmailUseCase {
private readonly logger = new Logger(RegisterSentEmailUseCase.name);
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
async execute(ticketIdRaw: string, type: 'PRE' | 'FULL', messageId: string): Promise<void> {
this.logger.log(`Registering Message-ID for Ticket ${ticketIdRaw} (${type}): ${messageId}`);
const ticket = await this.ticketRepo.findById(new TicketId(ticketIdRaw));
if (!ticket) throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
ticket.registerSentEmailInfo(type, messageId);
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,29 @@
import {
Inject,
Injectable,
Logger,
NotFoundException
} from '@nestjs/common';
import { TicketId } from '../domain/ticket.entity';
import { TICKET_REPOSITORY, TicketRepository } from '../domain/ticket.repository.interface';
@Injectable()
export class RequestTicketApprovalUseCase {
private readonly logger = new Logger(RequestTicketApprovalUseCase.name);
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
async execute(ticketIdRaw: string, recipient: string) {
this.logger.log(`requesting ticket approval`);
const ticketId = new TicketId(ticketIdRaw);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
}
ticket.requestApproval(recipient);
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,27 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { TicketId } from "../domain/ticket.entity";
import { TICKET_REPOSITORY, TicketRepository } from "../domain/ticket.repository.interface";
@Injectable()
export class SendApprovalReminderUseCase {
private readonly logger = new Logger(SendApprovalReminderUseCase.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(ticketIdRaw: string) {
this.logger.log(`sending approval reminder for ticket ${ticketIdRaw}`);
const ticketId = new TicketId(ticketIdRaw);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
}
ticket.sendApprovalReminder();
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,39 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { TicketId } from '../domain/ticket.entity';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../domain/ticket.repository.interface';
import { TransportDocument } from '../domain/value-objects/transport-document.vo';
@Injectable()
export class UploadTransportDocumentUseCase {
private readonly logger = new Logger(UploadTransportDocumentUseCase.name);
constructor(
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
async execute(ticketIdRaw: string, file: Express.Multer.File) {
this.logger.log(
`adding uploaded TransportDocument to Ticket ${ticketIdRaw}`,
);
const ticketId = new TicketId(ticketIdRaw);
const ticket = await this.ticketRepo.findById(ticketId);
if (!ticket) {
throw new NotFoundException(`Ticket ${ticketIdRaw} not found`);
}
const doc = new TransportDocument(
file.path,
file.originalname,
file.mimetype,
file.size,
);
ticket.registerDigitalDocumentUpload(doc);
await this.ticketRepo.save(ticket);
}
}
@@ -0,0 +1,15 @@
export abstract class AggregateRoot {
private _domainEvents: any[] = [];
protected addDomainEvent(event: any): void {
this._domainEvents.push(event);
}
public get domainEvents(): any[] {
return [...this._domainEvents];
}
public clearDomainEvents(): void {
this._domainEvents = [];
}
}
@@ -0,0 +1,16 @@
export class PatientId {
constructor(public readonly value: string) {}
}
export interface PatientCandidate {
id: PatientId;
firstName: string;
lastName: string;
birthday: Date | null;
healthInsurance: string | null;
insuranceNo: string | null;
careDegree: string | null;
street: string | null,
city: string | null,
zip: string | null,
}
@@ -0,0 +1,39 @@
import { StringifyOptions } from 'querystring';
import { TicketId } from '../ticket.entity';
export class TourId {
constructor(public readonly value: string) {}
}
export interface TourCandidate {
id: TourId;
operationId: string;
category: string;
ordinanceType: string;
transportType: string;
patientId: string;
patientName: string;
patientSurname: string;
patientStreet: string;
patientCity: string;
patientZip: string;
healthInsurance: string;
insuranceNo: string;
hasInfection: boolean;
infectionName: string;
seriesEndDate: Date | null;
direction: string;
occupiedKm: number;
targetStreet: string;
targetCity: string;
targetZip: string;
startStreet: string;
startCity: string;
startZip: string;
startDate: Date;
rangeEndDate: Date;
ticketId: TicketId;
}
@@ -0,0 +1,7 @@
import { PatientCandidate } from '../interfaces/patient-candidate.interface';
export const PATIENT_PROVIDER = 'PatientProvider';
export interface PatientProvider {
findPatientById(id: string): Promise<PatientCandidate | null>;
}
@@ -0,0 +1,9 @@
import { TourCandidate } from "../interfaces/tour-candidate.interface";
export const TOUR_PROVIDER = 'TourProvider';
export interface TourProvider {
findUnticketedTours(): Promise<TourCandidate[]>;
findTourById(id: string): Promise<TourCandidate | null>
}
@@ -0,0 +1,20 @@
import { Inject, Injectable } from "@nestjs/common";
import { TICKET_REPOSITORY, TicketRepository } from "../ticket.repository.interface";
import { TourCandidate } from "../interfaces/tour-candidate.interface";
import { Ticket } from "../ticket.entity";
@Injectable()
export class TicketMatchingService {
constructor(@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository) {}
async findMatchingTicket(tour: TourCandidate): Promise<Ticket | null> {
const returnTripTicket = await this.ticketRepo.findTicketForTripInOppositeDirection(tour);
if(returnTripTicket) return returnTripTicket;
const seriesTicket = await this.ticketRepo.findTicketForSeries(tour);
if (seriesTicket) return seriesTicket;
return null;
}
}
@@ -0,0 +1,242 @@
import 'reflect-metadata';
import {
ApprovalRequestStatus,
ApprovalRequirement,
TicketStage,
} from '@avicenna/shared-dtos';
import { v4 as uuidv4 } from 'uuid';
import { Ticket, TicketId } from '../ticket.entity';
import { TicketPatientData } from '../value-objects/ticket-patient-data.vo';
import { TicketTourData } from '../value-objects/ticket-tour-data.vo';
import { TransportDocument } from '../value-objects/transport-document.vo';
import { PatientId } from '../interfaces/patient-candidate.interface';
import { TourId } from '../interfaces/tour-candidate.interface';
export class TicketBuilder {
private props: any;
constructor() {
// STANDARD: Ein valides Ticket, das "READY" sein sollte.
// Wir setzen hier Werte, die validation checks bestehen.
this.props = {
id: uuidv4(),
stage: TicketStage.DRAFT,
patient: this.createValidPatientData(),
approval: {
req: ApprovalRequirement.NOT_REQUIRED,
status: ApprovalRequestStatus.NOT_STARTED,
requestedAt: null,
lastInteractionAt: null,
reminderCount: 0,
recipient: null,
preInquiryMessageId: null,
fullRequestMessageId: null,
isOverriden: false,
},
hasPhysicalTD: false,
hasDigitalTD: false,
tours: [this.createValidTourData()], // Mindestens eine Tour für Validität
transportDocument: null,
createdAt: new Date(),
updatedAt: new Date(),
};
}
// --- MODIFIERS ---
public withId(id: string): this {
this.props.id = id;
return this;
}
public withStage(stage: TicketStage): this {
this.props.stage = stage;
return this;
}
// --- PATIENT ---
public withPatient(data: Partial<TicketPatientData>): this {
// Merge mit validen Daten, um nicht alles angeben zu müssen
const base = this.createValidPatientData();
// Achtung: Da TicketPatientData keine Setter hat, bauen wir ein Objekt,
// das der Struktur entspricht oder nutzen Reflection/Mocking.
// Sauberer Weg: Ein neues TicketPatientData instanziieren.
this.props.patient = new TicketPatientData(
// @ts-ignore - Zugriff auf private props simulation
data['sourceId'] || base['sourceId'],
data['sourceType'] || base['sourceType'],
data.firstName !== undefined ? data.firstName : base.firstName,
data.lastName !== undefined ? data.lastName : base.lastName,
data.birthday || base.birthday,
data.healthInsurance !== undefined
? data.healthInsurance
: base.healthInsurance,
data.insuranceNo || base.insuranceNo,
data.street || base.street,
data.city || base.city,
data.zip || base.zip,
);
return this;
}
public withMissingPatientData(): this {
// Erzeugt invalides PatientData VO
this.props.patient = new TicketPatientData(
new PatientId('p-1'),
'PATIENT',
null, // Invalid
null, // Invalid
new Date(),
null, // Invalid
null,
'Musterstr.',
'Musterstadt',
'12345',
);
return this;
}
public withoutPatient(): this {
this.props.patient = null;
return this;
}
// --- TOURS ---
public withTours(tours: TicketTourData[]): this {
this.props.tours = tours;
return this;
}
public withInvalidTour(): this {
// Erzeugt eine Tour mit fehlenden Pflichtfeldern
const invalidTour = new TicketTourData(
new TourId('t-invalid'),
'op-1',
'', // Category missing
'', // Ordinance missing
'', // Transport missing
'p-1',
'Max',
'Mustermann',
'Str',
'City',
'12345',
'AOK',
'123',
'Hinfahrt',
10,
false,
null,
null,
'',
'',
'', // Target missing
'',
'',
'', // Start missing
new Date(),
);
this.props.tours = [invalidTour];
return this;
}
public withoutTours(): this {
this.props.tours = [];
return this;
}
// --- APPROVAL ---
public withApprovalRequirement(req: ApprovalRequirement): this {
this.props.approval.req = req;
return this;
}
public withApprovalStatus(status: ApprovalRequestStatus): this {
this.props.approval.status = status;
return this;
}
public withApprovalOverride(isOverriden: boolean): this {
this.props.approval.isOverriden = isOverriden;
return this;
}
// --- DOCUMENTS ---
public withDigitalTransportDocument(): this {
this.props.hasDigitalTD = true;
this.props.transportDocument = new TransportDocument(
'path/to/file',
'file.pdf',
'application/pdf',
1024,
);
return this;
}
public withPhysicalTransportDocument(): this {
this.props.hasPhysicalTD = true;
return this;
}
// --- BUILD ---
public build(): Ticket {
// Wir nutzen fromStorage, um den internen State exakt so zu setzen, wie wir wollen.
// WICHTIG: fromStorage ruft am Ende `reassessState()` auf.
// Das bedeutet, der Stage, den wir oben im Konstruktor setzen, wird sofort
// durch die echte Logik überschrieben. Das ist genau das, was wir testen wollen!
return Ticket.fromStorage(this.props);
}
// --- HELPERS ---
private createValidPatientData(): TicketPatientData {
return new TicketPatientData(
new PatientId('p-1'),
'PATIENT',
'Max',
'Mustermann',
new Date('1990-01-01'),
'AOK',
'V123456',
'Musterstraße 1',
'Musterstadt',
'12345',
);
}
private createValidTourData(): TicketTourData {
return new TicketTourData(
new TourId('t-1'),
'OP-123',
'Dialyse', // Category
'Verordnung', // Ordinance
'KTW', // TransportType
'p-1',
'Max',
'Mustermann',
'Str 1',
'City',
'12345',
'AOK',
'123',
'Hinfahrt',
15, // km
false,
null,
null,
'Zielstr 1',
'Zielstadt',
'54321', // Target
'Startstr 1',
'Startstadt',
'12345', // Start
new Date(),
);
}
}
@@ -0,0 +1,134 @@
import 'reflect-metadata';
import {
TicketStage,
ApprovalRequirement,
ApprovalRequestStatus,
} from '@avicenna/shared-dtos';
import { TicketBuilder } from './testing/ticket.builder';
describe('Ticket Entity - Validation & Lifecycle', () => {
describe('Stage: DATA_INCOMPLETE', () => {
it('should be DATA_INCOMPLETE if patient data is missing', () => {
const ticket = new TicketBuilder()
.withMissingPatientData() // Vorname, Nachname, Kasse fehlen
.build();
expect(ticket.currentStage).toBe(TicketStage.DATA_INCOMPLETE);
expect(ticket.validationErrors.length).toBeGreaterThan(0);
expect(ticket.validationErrors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'firstName' }),
expect.objectContaining({ field: 'lastName' }),
]),
);
});
it('should be DATA_INCOMPLETE if tour data is invalid (e.g. missing addresses)', () => {
const ticket = new TicketBuilder().withInvalidTour().build();
expect(ticket.currentStage).toBe(TicketStage.DATA_INCOMPLETE);
expect(ticket.validationErrors).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'startAddress' }),
expect.objectContaining({ field: 'category' }),
]),
);
});
});
describe('Stage: DRAFT', () => {
it('should be DRAFT if no tours are assigned', () => {
const ticket = new TicketBuilder().withoutTours().build();
expect(ticket.currentStage).toBe(TicketStage.DRAFT);
});
});
describe('Stage: ASSESSMENT_PENDING (Approval Logic)', () => {
it('should be ASSESSMENT_PENDING if approval is REQUIRED but not requested yet', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
.withApprovalStatus(ApprovalRequestStatus.NOT_STARTED)
.build();
expect(ticket.validationErrors).toHaveLength(0); // Daten sind valide
expect(ticket.currentStage).toBe(TicketStage.ASSESSMENT_PENDING);
});
it('should transition to APPROVAL_IN_PROGRESS after full request sent', () => {
// Wir simulieren hier den State NACHDEM angefragt wurde
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
.withApprovalStatus(ApprovalRequestStatus.FULL_REQUEST_SENT)
.build();
expect(ticket.currentStage).toBe(TicketStage.APPROVAL_IN_PROGRESS);
});
it('should NOT be pending if approval is NOT_REQUIRED', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
.build();
// Sollte weiter zum nächsten Check (Documents) gehen
// Da im Builder default keine Docs da sind -> DOCUMENTS_PENDING
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
});
it('should NOT be pending if approval is GRANTED', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.REQUIRED)
.withApprovalStatus(ApprovalRequestStatus.GRANTED)
.build();
// Genehmigt -> Weiter zu Documents
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
});
});
describe('Stage: DOCUMENTS_PENDING vs READY', () => {
it('should be DOCUMENTS_PENDING if valid, approval clear, but no doc uploaded', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
// Kein Dokument gesetzt
.build();
expect(ticket.currentStage).toBe(TicketStage.DOCUMENTS_PENDING);
});
it('should be READY if digital document is present', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
.withDigitalTransportDocument()
.build();
expect(ticket.currentStage).toBe(TicketStage.READY);
});
it('should be READY if physical document is present', () => {
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED)
.withPhysicalTransportDocument()
.build();
expect(ticket.currentStage).toBe(TicketStage.READY);
});
});
describe('Approval Override Logic', () => {
it('should respect manual override to NOT_REQUIRED even if logic says REQUIRED', () => {
// Zuerst ein Ticket, das theoretisch Genehmigung bräuchte (wird durch reassessState ermittelt)
// Aber: Wir setzen isOverridden = true
const ticket = new TicketBuilder()
.withApprovalRequirement(ApprovalRequirement.NOT_REQUIRED) // Manuell gesetzt
.withApprovalOverride(true)
.build();
// Da Override true ist, darf determineApprovalRequirement() im Entity NICHT laufen
// und den Wert überschreiben.
expect(ticket.approval.requirement).toBe(
ApprovalRequirement.NOT_REQUIRED,
);
});
});
});
@@ -0,0 +1,640 @@
import {
ApprovalRequestStatus,
ApprovalRequirement,
TicketStage,
ValidationError,
} from '@avicenna/shared-dtos';
import { differenceInDays } from 'date-fns';
import { AggregateRoot } from './aggregate-root';
import {
PatientCandidate,
PatientId,
} from './interfaces/patient-candidate.interface';
import { TourCandidate } from './interfaces/tour-candidate.interface';
import {
TicketApprovalGrantedEvent,
TicketApprovalReminderSentEvent,
TicketApprovalRequestedEvent,
TicketApprovalRequirementOverriddenEvent,
TicketCreatedEvent,
TicketPhysicalTransportDocumentReceivedEvent,
TicketTransportDocumentUploadedEvent,
TourAssignedToTicketEvent,
} from './ticket.events';
import { TicketPatientData } from './value-objects/ticket-patient-data.vo';
import { TicketTourData } from './value-objects/ticket-tour-data.vo';
import { TransportDocument } from './value-objects/transport-document.vo';
class DomainError {
constructor(public readonly message: string) {}
}
export class ApprovalWorkflow {
constructor(
public readonly requirement: ApprovalRequirement,
public readonly status: ApprovalRequestStatus,
public readonly requestedAt: Date | null,
public readonly lastInteractionAt: Date | null,
public readonly reminderCount: number,
public readonly recipientEmail: string | null,
public readonly preInquiryMessageId: string | null = null,
public readonly fullRequestMessageId: string | null = null,
public readonly isOverridden: boolean = false,
) {}
static initial(): ApprovalWorkflow {
return new ApprovalWorkflow(
ApprovalRequirement.UNKOWN,
ApprovalRequestStatus.NOT_STARTED,
null,
null,
0,
null,
null,
null,
false,
);
}
isOverdue(): boolean {
if (!this.lastInteractionAt) return false;
return differenceInDays(new Date(), this.lastInteractionAt) >= 3;
}
hasRemindersLeft(): boolean {
return this.reminderCount < 3;
}
setRequirement(req: ApprovalRequirement): ApprovalWorkflow {
if (this.status === ApprovalRequestStatus.GRANTED) return this;
return new ApprovalWorkflow(
req,
this.status,
this.requestedAt,
new Date(),
this.reminderCount,
this.recipientEmail,
this.preInquiryMessageId,
this.fullRequestMessageId,
this.isOverridden,
);
}
overrideRequirement(newReq: ApprovalRequirement): ApprovalWorkflow {
return new ApprovalWorkflow(
newReq,
this.status,
this.requestedAt,
this.lastInteractionAt,
this.reminderCount,
this.recipientEmail,
this.preInquiryMessageId,
this.fullRequestMessageId,
true,
);
}
markAsRequested(
hasDigitalDoc: boolean,
recipient: string | undefined,
isExternal = false,
): ApprovalWorkflow {
if (this.status === ApprovalRequestStatus.FULL_REQUEST_SENT) return this;
if (this.status === ApprovalRequestStatus.GRANTED) return this;
let newStatus: ApprovalRequestStatus;
if (isExternal) {
newStatus = ApprovalRequestStatus.EXTERNAL_REQUEST;
} else if (hasDigitalDoc) {
newStatus = ApprovalRequestStatus.FULL_REQUEST_SENT;
} else {
newStatus = ApprovalRequestStatus.PRE_INQUIRY_SENT;
}
return new ApprovalWorkflow(
this.requirement,
newStatus,
this.requestedAt ?? new Date(),
new Date(),
this.reminderCount,
recipient,
this.preInquiryMessageId,
this.fullRequestMessageId,
this.isOverridden,
);
}
grantApproval(): ApprovalWorkflow {
return new ApprovalWorkflow(
this.requirement,
ApprovalRequestStatus.GRANTED,
this.requestedAt,
new Date(),
this.reminderCount,
this.recipientEmail,
this.preInquiryMessageId,
this.fullRequestMessageId,
this.isOverridden,
);
}
recordReminder(): ApprovalWorkflow {
return new ApprovalWorkflow(
this.requirement,
this.status,
this.requestedAt,
new Date(),
this.reminderCount + 1,
this.recipientEmail,
this.preInquiryMessageId,
this.fullRequestMessageId,
this.isOverridden,
);
}
registerSentEmail(type: 'PRE' | 'FULL', messageId: string): ApprovalWorkflow {
return new ApprovalWorkflow(
this.requirement,
this.status,
this.requestedAt,
this.lastInteractionAt,
this.reminderCount,
this.recipientEmail,
type === 'PRE' ? messageId : this.preInquiryMessageId,
type === 'FULL' ? messageId : this.fullRequestMessageId,
this.isOverridden,
);
}
}
export class TicketId {
constructor(public readonly value: string) {}
}
export class Ticket extends AggregateRoot {
private _stage: TicketStage;
private _tours: Map<string, TicketTourData> = new Map();
private _patientId: PatientId | null;
private _patientData: TicketPatientData | null = null;
private _validationErrors: ValidationError[] = [];
private _approval: ApprovalWorkflow;
private _hasDigitalTransportDocument: boolean = false;
private _hasPhysicalTransportDocument: boolean = false;
private _createdAt: Date;
private _updatedAt: Date;
private _transportDocument: TransportDocument | null = null;
get patientId(): PatientId | null {
return this._patientId;
}
get transportDocument(): TransportDocument | null {
return this._transportDocument;
}
get hasDigitalTransportDocument(): boolean {
return this._hasDigitalTransportDocument;
}
get hasPhysicalTransportDocument(): boolean {
return this._hasPhysicalTransportDocument;
}
get validationErrors(): ValidationError[] {
return [...this._validationErrors];
}
get assignedTourIds(): string[] {
return [...this._tours.keys()];
}
get currentStage(): TicketStage {
return this._stage;
}
get createdAt(): Date {
return this._createdAt;
}
get updatedAt(): Date {
return this._updatedAt;
}
get approval(): ApprovalWorkflow {
return new ApprovalWorkflow(
this._approval.requirement,
this._approval.status,
this._approval.requestedAt,
this._approval.lastInteractionAt,
this._approval.reminderCount,
this._approval.recipientEmail,
this._approval.preInquiryMessageId,
this._approval.fullRequestMessageId,
this._approval.isOverridden,
);
}
get patient(): TicketPatientData {
return this._patientData;
}
get tours(): TicketTourData[] {
return Array.from(this._tours.values()).sort(
(a, b) => b.startDate.getTime() - a.startDate.getTime(),
);
}
private constructor(
public readonly id: TicketId,
patientId: PatientId,
) {
super();
this._stage = TicketStage.DRAFT;
this._approval = ApprovalWorkflow.initial();
this._patientId = patientId;
this._createdAt = new Date();
this._updatedAt = new Date();
}
public updatePatientData(masterData?: PatientCandidate): void {
if (masterData) {
// if (masterData.id.value !== this.patientId.value)
// throw new DomainError('Patient ID mismatch');
this._patientData = TicketPatientData.fromMasterData(masterData);
this._patientId = masterData.id;
} else if (this._tours.size > 0) {
const firstTour = this._tours.values().next().value;
this._patientData = TicketPatientData.fromTourSnapshot(firstTour);
}
this.reassessState();
}
public assignTour(tour: TourCandidate): void {
if (this._tours.has(tour.id.value)) {
return;
}
this.ensureTicketIsOpen();
// this.ensurePatientMatches(tour.patientId);
const tourData = TicketTourData.fromCandidate(tour);
if (!this._tours.has(tourData.id.value)) {
this._tours.set(tourData.id.value, tourData);
this.addDomainEvent(
new TourAssignedToTicketEvent(this.id.value, tour.id.value),
);
} else {
this._tours.set(tourData.id.value, tourData);
}
this.reassessState();
}
public registerDigitalDocumentUpload(doc: TransportDocument): void {
this._transportDocument = doc;
if (!this._hasDigitalTransportDocument) {
this._hasDigitalTransportDocument = true;
this.addDomainEvent(
new TicketTransportDocumentUploadedEvent(this.id.value),
);
this.reassessState();
}
}
public registerSentEmailInfo(type: 'PRE' | 'FULL', messageId: string): void {
this._approval = this._approval.registerSentEmail(type, messageId);
this.reassessState();
}
public receivePhysicalDocument(): void {
const oldState = this._hasPhysicalTransportDocument;
if (!this._hasPhysicalTransportDocument) {
this._hasPhysicalTransportDocument = true;
if (oldState != true) {
this.addDomainEvent(
new TicketPhysicalTransportDocumentReceivedEvent(this.id.value),
);
}
this.reassessState();
}
}
public overrideApprovalRequirement(newReq: ApprovalRequirement): void {
this.ensureTicketIsOpen();
this._approval = this._approval.overrideRequirement(newReq);
this.addDomainEvent(
new TicketApprovalRequirementOverriddenEvent(this.id.value, newReq),
);
this.reassessState();
}
public requestApproval(recipient: string): void {
if (this._approval.requirement !== ApprovalRequirement.REQUIRED) {
throw new DomainError(
`Genehmigung wird nicht benötigt, Anfrage abgebrochen`,
);
}
const prevStatus = this._approval.status;
this._approval = this._approval.markAsRequested(
this._hasDigitalTransportDocument,
recipient,
);
if (this._approval.status !== prevStatus) {
this.addDomainEvent(
new TicketApprovalRequestedEvent(
this.id.value,
this._approval.status,
recipient,
),
);
}
this.reassessState();
}
public registerExternalRequest(): void {
if (this._approval.requirement !== ApprovalRequirement.REQUIRED) {
throw new DomainError(
`Genehmigung wird nicht benötigt, Anfrage abgebrochen`,
);
}
const prevStatus = this._approval.status;
this._approval = this._approval.markAsRequested(
this._hasDigitalTransportDocument,
undefined,
true,
);
if (this._approval.status !== prevStatus) {
this.addDomainEvent(
new TicketApprovalRequestedEvent(
this.id.value,
this._approval.status,
this._approval.recipientEmail,
),
);
}
this.reassessState();
}
public sendApprovalReminder(): void {
this.ensureTicketIsOpen();
if (
this._approval.status !== ApprovalRequestStatus.PRE_INQUIRY_SENT &&
this._approval.status !== ApprovalRequestStatus.FULL_REQUEST_SENT
) {
throw new DomainError(
'cannot send reminder: Approval is not in a requested state.',
);
}
if (!this._approval.hasRemindersLeft()) {
throw new DomainError(
'cannot send reminder: Max reminder count reached.',
);
}
this._approval = this._approval.recordReminder();
this.addDomainEvent(
new TicketApprovalReminderSentEvent(
this.id.value,
this._approval.reminderCount,
this._approval.recipientEmail,
),
);
this.reassessState();
}
public grantApproval(): void {
this.ensureTicketIsOpen();
if (this._approval.status === ApprovalRequestStatus.GRANTED) return;
this._approval = this._approval.grantApproval();
this.addDomainEvent(new TicketApprovalGrantedEvent(this.id.value));
this.reassessState();
}
public toPrimitives() {
return {
id: this.id.value,
patientId: this._patientId.value,
stage: this._stage,
approvalRequirement: this._approval.requirement,
approvalStatus: this._approval.status,
approvalRequestedAt: this._approval.requestedAt,
approvalLastInteractionAt: this._approval.lastInteractionAt,
approvalReminderCount: this._approval.reminderCount,
approvalRecipient: this._approval.recipientEmail,
approvalPreInquiryMessageId: this._approval.preInquiryMessageId,
approvalFullRequestMessageId: this._approval.fullRequestMessageId,
approvalIsOverriden: this._approval.isOverridden,
hasDigitalTransportDocument: this._hasDigitalTransportDocument,
hasPhysicalTransportDocument: this._hasPhysicalTransportDocument,
transportDocument: this._transportDocument
? {
...this.transportDocument,
}
: null,
assignedTourIds: [...this._tours.keys()],
createdAt: this._createdAt,
updateAt: this._updatedAt,
};
}
static create(id: TicketId, patientId: string): Ticket {
const ticket = new Ticket(id, new PatientId(patientId));
ticket.addDomainEvent(new TicketCreatedEvent(id.value, patientId));
return ticket;
}
static fromStorage(props: {
id: string;
patient: TicketPatientData | null;
stage: string;
approval: {
req: string;
status: string;
requestedAt: Date | null;
lastInteractionAt: Date | null;
reminderCount: number;
recipient: string | null;
preInquiryMessageId: string | null;
fullRequestMessageId: string | null;
isOverriden: boolean;
};
hasPhysicalTD: boolean;
hasDigitalTD: boolean;
tours: TicketTourData[];
transportDocument: TransportDocument | null;
createdAt: Date;
updatedAt: Date;
// validationErrors: ValidationError[] // TODO: In DB persistieren, damit Frontend guenstig laden kann
}): Ticket {
const ticket = new Ticket(
new TicketId(props.id),
new PatientId(props.patient.patientId),
);
ticket._stage = props.stage as TicketStage;
ticket._approval = new ApprovalWorkflow(
props.approval.req as ApprovalRequirement,
props.approval.status as ApprovalRequestStatus,
props.approval.requestedAt,
props.approval.lastInteractionAt,
props.approval.reminderCount,
props.approval.recipient,
props.approval.preInquiryMessageId,
props.approval.fullRequestMessageId,
props.approval.isOverriden,
);
ticket._hasDigitalTransportDocument = props.hasDigitalTD;
ticket._hasPhysicalTransportDocument = props.hasPhysicalTD;
ticket._transportDocument = props.transportDocument;
ticket._patientData = props.patient;
for (const tourData of props.tours) {
ticket._tours.set(tourData.id.value, tourData);
}
ticket._createdAt = props.createdAt;
ticket._updatedAt = props.updatedAt;
ticket.reassessState();
return ticket;
}
private reassessState(): void {
this._updatedAt = new Date();
const errors: ValidationError[] = [];
// Ticket Validierung
for (const tour of this._tours.values()) {
errors.push(...tour.validate());
}
if (this._patientData) {
errors.push(...this._patientData.validate());
} else {
// if (this._tours.size > 0) {
// errors.push({
// field: 'patient',
// message: 'Keine Patientendaten verfügbar.',
// source,
// });
// }
}
this._validationErrors = errors;
if (this._tours.size === 0) {
this._stage = TicketStage.DRAFT;
return;
}
if (this._validationErrors.length > 0) {
this._stage = TicketStage.DATA_INCOMPLETE;
return;
}
// Genehmigungsverfahren
if (!this._approval.isOverridden) {
this.determineApprovalRequirement();
}
if (
this._approval.requirement === ApprovalRequirement.REQUIRED &&
this._approval.status !== ApprovalRequestStatus.GRANTED
) {
if (
this._approval.status === ApprovalRequestStatus.FULL_REQUEST_SENT ||
this._approval.status === ApprovalRequestStatus.EXTERNAL_REQUEST
) {
this._stage = TicketStage.APPROVAL_IN_PROGRESS;
} else {
this._stage = TicketStage.ASSESSMENT_PENDING;
}
return;
}
// Dokumente fehlen
if (
!this._hasPhysicalTransportDocument &&
!this._hasDigitalTransportDocument
) {
if (this._approval.requirement === ApprovalRequirement.REQUIRED) {
this._stage = TicketStage.APPROVAL_IN_PROGRESS;
}
this._stage = TicketStage.DOCUMENTS_PENDING;
return;
}
// Alles ready
this._stage = TicketStage.READY;
}
private ensureTicketIsOpen(): void {
if (this._stage === TicketStage.ARCHIVED) {
throw new DomainError('Cannot modify archived ticket.');
}
}
private ensurePatientMatches(tourPatientId: string): void {
if (this.patientId && this.patientId.value !== tourPatientId) {
throw new DomainError('Patient mismatch.');
}
}
private determineApprovalRequirement(): void {
const tour = this._tours.values().next().value;
let req = ApprovalRequirement.UNKOWN;
if (tour) {
if (
tour.ordinanceType.indexOf('BTW') > -1 ||
tour.ordinanceType.indexOf('TSW') > -1
) {
req = ApprovalRequirement.NOT_REQUIRED;
} else if (
[
'1. Entlassung',
'2. Entlassung Vorbestellung',
'3. Einweisung',
'5. Verlegung',
'6. Barzahlung',
'Privatfahrt Rechnung',
'Reha',
'Tagesklinik - AKW',
'Tagesklinik - ASK',
'Tagesklinik - MKH',
'Transportschein abholen',
].includes(tour.category) ||
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
) {
req = ApprovalRequirement.NOT_REQUIRED;
} else {
req = ApprovalRequirement.REQUIRED;
}
}
this._approval = this._approval.setRequirement(req);
}
}
@@ -0,0 +1,61 @@
import {
ApprovalRequestStatus,
ApprovalRequirement,
} from '@avicenna/shared-dtos';
export class TicketCreatedEvent {
constructor(
public readonly ticketId: string,
public readonly patientId: string,
) {}
}
export class TourAssignedToTicketEvent {
constructor(
public readonly ticketId: string,
public readonly tourId: string,
) {}
}
export class TicketStatusChangedEvent {
constructor(
public readonly ticketId: string,
public readonly oldStage: string,
public readonly newStage: string,
) {}
}
export class TicketTransportDocumentUploadedEvent {
constructor(public readonly ticketId: string) {}
}
export class TicketPhysicalTransportDocumentReceivedEvent {
constructor(public readonly ticketId: string) {}
}
export class TicketApprovalRequirementOverriddenEvent {
constructor(
public readonly ticketId: string,
public readonly newRequirement: ApprovalRequirement,
) {}
}
export class TicketApprovalRequestedEvent {
constructor(
public readonly ticketId: string,
public readonly approvalStatus: ApprovalRequestStatus,
public readonly recipientEmail: string,
) {}
}
export class TicketApprovalReminderSentEvent {
constructor(
public readonly ticketId: string,
public readonly reminderCount: number,
public readonly recipientEmail: string,
) {}
}
export class TicketApprovalGrantedEvent {
constructor(public readonly ticketId: string) {}
}
@@ -0,0 +1,22 @@
import { TourFile } from '@prisma/client';
import { TourCandidate } from './interfaces/tour-candidate.interface';
import { Ticket, TicketId } from './ticket.entity';
export const TICKET_REPOSITORY = 'TicketRepository';
export interface TicketRepository {
findById(id: TicketId): Promise<Ticket | null>;
save(ticket: Ticket): Promise<void>;
findTicketForTripInOppositeDirection(
tour: TourCandidate,
): Promise<Ticket | null>;
findTicketForSeries(tour: TourCandidate): Promise<Ticket | null>;
findPendingFilesForTicket(ticketId: TicketId): Promise<string[]>;
streamAllActive(batchSize: number): AsyncGenerator<Ticket[]>;
/**
* BATCH OPERATION: Bypasses Ticket aggregate for performance.
* Does NOT trigger Domain Events.
* Use only for background maintenance.
*/
closeSettledTickets(): Promise<void>;
}
@@ -0,0 +1,92 @@
import { ValidationError } from '@avicenna/shared-dtos';
import {
PatientCandidate,
PatientId,
} from '../interfaces/patient-candidate.interface';
import { TourCandidate, TourId } from '../interfaces/tour-candidate.interface';
import { TicketTourData } from './ticket-tour-data.vo';
export class TicketPatientData {
get patientId(): string | null {
return this.sourceType === 'PATIENT' ? this.sourceId.value : null;
}
constructor(
private readonly sourceId: PatientId | TourId,
private readonly sourceType: 'PATIENT' | 'TOUR',
public readonly firstName: string | null,
public readonly lastName: string | null,
public readonly birthday: Date | null,
public readonly healthInsurance: string | null,
public readonly insuranceNo: string | null,
public readonly street: string | null,
public readonly city: string | null,
public readonly zip: string | null,
) {}
static fromMasterData(patient: PatientCandidate): TicketPatientData {
return new TicketPatientData(
patient.id,
'PATIENT',
patient.firstName,
patient.lastName,
patient.birthday,
patient.healthInsurance,
patient.insuranceNo,
patient.street,
patient.city,
patient.zip,
);
}
static fromTour(tour: TourCandidate): TicketPatientData {
return new TicketPatientData(
tour.id,
'TOUR',
tour.patientName,
tour.patientSurname,
null,
tour.healthInsurance,
tour.insuranceNo,
tour.patientStreet,
tour.patientCity,
tour.patientZip,
);
}
static fromTourSnapshot(tour: TicketTourData): TicketPatientData {
return new TicketPatientData(
tour.id,
'TOUR',
tour.patientName,
tour.patientSurname,
null,
tour.healthInsurance,
tour.insuranceNo,
tour.patientStreet,
tour.patientCity,
tour.patientZip,
);
}
public validate(): ValidationError[] {
const errors: ValidationError[] = [];
const source = { type: this.sourceType, id: this.sourceId.value };
if (!this.firstName) {
errors.push({ field: 'firstName', message: 'Vorname fehlt', source });
}
if (!this.lastName) {
errors.push({ field: 'lastName', message: 'Nachname fehlt', source });
}
if (!this.healthInsurance) {
errors.push({
field: 'healthInsurance',
message: 'Krankenkasse fehlt',
source,
});
}
return errors;
}
}
@@ -0,0 +1,120 @@
import { ValidationError } from '@avicenna/shared-dtos';
import { TourCandidate, TourId } from '../interfaces/tour-candidate.interface';
export class TicketTourData {
constructor(
public readonly id: TourId,
public readonly operationId: string,
public readonly category: string,
public readonly ordinanceType: string,
public readonly transportType: string,
public readonly patientId: string,
public readonly patientName: string,
public readonly patientSurname: string,
public readonly patientStreet: string,
public readonly patientCity: string,
public readonly patientZip: string,
public readonly healthInsurance: string,
public readonly insuranceNo: string,
public readonly direction: string,
public readonly occupiedKm: number,
public readonly hasInfection: boolean,
public readonly infectionName: string | null,
public readonly seriesEndDate: Date | null,
public readonly targetStreet: string,
public readonly targetCity: string,
public readonly targetZip: string,
public readonly startStreet: string,
public readonly startCity: string,
public readonly startZip: string,
public readonly startDate: Date,
) {}
static fromCandidate(candidate: TourCandidate): TicketTourData {
return new TicketTourData(
candidate.id,
candidate.operationId,
candidate.category,
candidate.ordinanceType,
candidate.transportType,
candidate.patientId,
candidate.patientName,
candidate.patientSurname,
candidate.patientStreet,
candidate.patientCity,
candidate.patientZip,
candidate.healthInsurance,
candidate.insuranceNo,
candidate.direction,
candidate.occupiedKm,
candidate.hasInfection,
candidate.infectionName,
candidate.seriesEndDate,
candidate.targetStreet,
candidate.targetCity,
candidate.targetZip,
candidate.startStreet,
candidate.startCity,
candidate.startZip,
candidate.startDate,
);
}
public validate(): ValidationError[] {
const errors: ValidationError[] = [];
const source = { type: 'TOUR' as const, id: this.id.value };
if (!this.ordinanceType) {
errors.push({
field: 'ordinanceType',
message: `Verordnungsart ist nicht gefüllt`,
source,
});
}
if (!this.transportType) {
errors.push({
field: 'transportType',
message: `Transportart ist nicht gefüllt`,
source,
});
}
// REASON: Dispolive struggles to populate this field with it's value.
// if (!this.category) {
// errors.push({
// field: 'category',
// message: `Kategorie ist nicht gefüllt`,
// source,
// });
// }
if (!this.startDate) {
errors.push({
field: 'startDate',
message: `Startzeit ist nicht gefüllt`,
source,
});
}
if (!this.startStreet || !this.startZip || !this.startCity) {
errors.push({
field: 'startAddress',
message: `Start Adresse ist nicht gefüllt`,
source,
});
}
if (!this.targetStreet || !this.targetZip || !this.targetCity) {
errors.push({
field: 'targetAddress',
message: `Ziel Adresse ist nicht gefüllt`,
source,
});
}
if (this.occupiedKm > 9990) {
errors.push({
field: 'occupiedKm',
message: `Besetzte Kilometer sind zu hoch`,
source,
});
}
return errors;
}
}
@@ -0,0 +1,8 @@
export class TransportDocument {
constructor(
public readonly storagePath: string,
public readonly originalFilename: string,
public readonly mimeType: string,
public readonly sizeBytes: number,
) {}
}
@@ -0,0 +1,41 @@
export const TICKET_EMAIL_QUEUE = 'ticket-email-queue';
export type TicketEmailJob =
| { type: 'send-pre-inquiry'; data: PreInquiryJobData }
| { type: 'send-full-request'; data: FullRequestJobData }
| { type: 'send-reminder'; data: ReminderJobData };
export interface BaseEmailJobData {
ticketId: string;
recipientEmail: string;
// Für Threading: Auf welche ID soll geantwortet werden?
replyToMessageId?: string;
}
export interface PreInquiryJobData extends BaseEmailJobData {
params: {
patientName: string;
birthday: string;
insuranceNo: string;
healthInsurance: string;
startAddressLine: string;
targetAddressLine: string;
transportDate: string;
};
}
export interface FullRequestJobData extends BaseEmailJobData {
// Wir mappen das Ticket-VO auf eine flache Struktur für den Worker
attachment: {
storagePath: string;
originalFilename: string;
mimeType: string;
};
patientName: string; // Für Betreff/Body
}
export interface ReminderJobData extends BaseEmailJobData {
patientName: string;
transportDate: Date;
reminderCount: number;
}
@@ -0,0 +1,430 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { endOfDay, startOfDay } from 'date-fns';
import { Kysely, sql } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { DB } from 'src/core/database/types';
import {
TourCandidate,
TourId,
} from '../domain/interfaces/tour-candidate.interface';
import { Ticket, TicketId } from '../domain/ticket.entity';
import { TicketRepository } from '../domain/ticket.repository.interface';
import { TicketTourData } from '../domain/value-objects/ticket-tour-data.vo';
import { v4 as uuidv4 } from 'uuid';
import { TransportDocument } from '../domain/value-objects/transport-document.vo';
import { TicketStage } from '@avicenna/shared-dtos';
import { TicketPatientData } from '../domain/value-objects/ticket-patient-data.vo';
import { PatientId } from '../domain/interfaces/patient-candidate.interface';
@Injectable()
export class KyselyTicketRepository implements TicketRepository {
constructor(
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
private readonly eventEmitter: EventEmitter2,
) {}
async *streamAllActive(batchSize = 100): AsyncGenerator<Ticket[]> {
let offset = 0;
while (true) {
const rows = await this.db
.selectFrom('Ticket')
.select('id')
.where('Ticket.stage', 'not in', [
TicketStage.ARCHIVED,
TicketStage.CLOSED,
])
.offset(offset)
.limit(batchSize)
.execute();
if (rows.length === 0) break;
const tickets = await Promise.all(
rows.map(({ id }) => this.findById(new TicketId(id))),
);
yield tickets.filter((t): t is Ticket => t !== null);
offset += batchSize;
}
}
async findPendingFilesForTicket(ticketId: TicketId): Promise<string[]> {
const row = await this.db
.selectFrom('TourFile')
.select('TourFile.id')
.where('TourFile.ticketId', '=', ticketId.value)
.where('TourFile.dlUploadStatus', '=', 'pending')
.execute();
return row.map(({ id }) => id);
}
async findTicketForSeries(tour: TourCandidate): Promise<Ticket | null> {
const institutionStreet =
tour.direction === 'Hinfahrt' ? tour.targetStreet : tour.startStreet;
const result = await this.db
.selectFrom('Tour')
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.select('Ticket.id')
.where('type', '=', 'Serie')
.where('category', '=', tour.category)
.where('ordinanceType', '=', tour.ordinanceType)
.where('Tour.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();
if (!result) return null;
return this.loadAggregate(new TicketId(result.id));
}
async findTicketForTripInOppositeDirection(
tour: TourCandidate,
): Promise<Ticket | null> {
const dayStart = startOfDay(new Date(tour.startDate));
const dayEnd = endOfDay(new Date(tour.startDate));
const oppositeDirection =
tour.direction === 'Hinfahrt' ? 'Rückfahrt' : 'Hinfahrt';
const result = await this.db
.selectFrom('Tour')
.innerJoin('Ticket', 'Ticket.id', 'Tour.ticketId')
.select('Ticket.id')
.where('startDate', '>', dayStart)
.where('startDate', '<', dayEnd)
.where('startStreet', '=', tour.targetStreet)
.where('targetStreet', '=', tour.startStreet)
.where('direction', '=', oppositeDirection)
.executeTakeFirst();
if (!result) return null;
return this.loadAggregate(new TicketId(result.id));
}
async findById(id: TicketId): Promise<Ticket | null> {
return this.loadAggregate(id);
}
/**
* BATCH OPERATION: Bypasses Ticket aggregate for performance.
* Does NOT trigger Domain Events.
* Use only for background maintenance.
*/
async closeSettledTickets(): Promise<void> {
await this.db
.updateTable('Ticket')
.set((eb) => ({
stage: 'CLOSED',
lastStageBeforeClosed: eb.ref('Ticket.stage'),
}))
.where('stage', 'not in', [
TicketStage.ARCHIVED,
TicketStage.CLOSED,
TicketStage.READY,
])
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom('Tour')
.select('Tour.id')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.where('Tour.check', '<', 2),
),
),
)
.execute();
}
async save(ticket: Ticket): Promise<void> {
const data = ticket.toPrimitives();
await this.db.transaction().execute(async (trx) => {
await trx
.insertInto('Ticket')
.values({
id: data.id,
patientId: data.patientId,
stage: data.stage,
approvalRequirement: data.approvalRequirement,
approvalStatus: data.approvalStatus,
approvalRequestedAt: data.approvalRequestedAt,
approvalLastInteractionAt: data.approvalLastInteractionAt,
approvalReminderCount: data.approvalReminderCount,
approvalRecipient: data.approvalRecipient,
approvalPreInquiryMessageId: data.approvalPreInquiryMessageId,
approvalFullRequestMessageId: data.approvalFullRequestMessageId,
approvalIsOverriden: data.approvalIsOverriden,
hasDigitalTransportDocument: data.hasDigitalTransportDocument,
hasPhysicalTransportDocument: data.hasPhysicalTransportDocument,
createdAt: data.createdAt,
})
.onConflict((oc) =>
oc.column('id').doUpdateSet({
id: (eb) => eb.ref('excluded.id'),
stage: (eb) => eb.ref('excluded.stage'),
patientId: (eb) => eb.ref('excluded.patientId'),
approvalRequirement: (eb) => eb.ref('excluded.approvalRequirement'),
approvalStatus: (eb) => eb.ref('excluded.approvalStatus'),
approvalRequestedAt: (eb) => eb.ref('excluded.approvalRequestedAt'),
approvalLastInteractionAt: (eb) =>
eb.ref('excluded.approvalLastInteractionAt'),
approvalReminderCount: (eb) =>
eb.ref('excluded.approvalReminderCount'),
approvalRecipient: (eb) => eb.ref('excluded.approvalRecipient'),
approvalPreInquiryMessageId: (eb) =>
eb.ref('excluded.approvalPreInquiryMessageId'),
approvalFullRequestMessageId: (eb) =>
eb.ref('excluded.approvalFullRequestMessageId'),
approvalIsOverriden: (eb) => eb.ref('excluded.approvalIsOverriden'),
hasPhysicalTransportDocument: (eb) =>
eb.ref('excluded.hasPhysicalTransportDocument'),
hasDigitalTransportDocument: (eb) =>
eb.ref('excluded.hasDigitalTransportDocument'),
createdAt: (eb) => eb.ref('excluded.createdAt'),
updatedAt: data.updateAt,
}),
)
.execute();
if (data.assignedTourIds.length > 0) {
await trx
.updateTable('Tour')
.set({ ticketId: data.id })
.where('id', 'in', data.assignedTourIds)
.execute();
}
if (data.transportDocument) {
for (const tourId of data.assignedTourIds) {
await trx
.insertInto('TourFile')
.values({
id: uuidv4(),
tourId: tourId,
ticketId: data.id,
storagePath: data.transportDocument.storagePath,
originalFilename: data.transportDocument.originalFilename,
mimeType: data.transportDocument.mimeType,
fileSizeBytes: data.transportDocument.sizeBytes,
dlUploadStatus: 'pending',
createdAt: new Date(),
})
.onConflict((oc) => oc.columns(['tourId', 'ticketId']).doNothing())
.execute();
}
}
});
const events = ticket.domainEvents;
for (const event of events) {
await this.eventEmitter.emit(event.constructor.name, event);
}
ticket.clearDomainEvents();
}
private async loadAggregate(id: TicketId): Promise<Ticket | null> {
const ticketRow = await this.db
.selectFrom('Ticket')
.selectAll()
.where('Ticket.id', '=', id.value)
.executeTakeFirst();
if (!ticketRow) return null;
const [tours, patient, doc] = await Promise.all([
this.loadToursForTicket(id),
this.loadPatientForTicket(id),
this.loadTransportDocumentForTicket(id),
]);
return this.mapToDomain(ticketRow, tours, patient, doc);
}
private mapToDomain(
row: any,
tours: TicketTourData[],
patient: TicketPatientData | null,
doc: TransportDocument | null,
): Ticket {
const patientData =
patient ?? TicketPatientData.fromTourSnapshot(tours.at(0));
return Ticket.fromStorage({
id: row.id,
patient: patientData,
stage: row.stage,
approval: {
req: row.approvalRequirement,
status: row.approvalStatus,
requestedAt: row.approvalRequestedAt,
lastInteractionAt: row.approvalLastInteractionAt,
reminderCount: row.approvalReminderCount ?? 0,
recipient: row.approvalRecipient,
preInquiryMessageId: row.approvalPreInquiryMessageId,
fullRequestMessageId: row.approvalFullRequestMessageId,
isOverriden: row.approvalIsOverriden,
},
hasPhysicalTD: row.hasPhysicalTransportDocument,
hasDigitalTD: row.hasDigitalTransportDocument,
transportDocument: doc,
tours: tours,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
});
}
private async loadPatientForTicket(
ticketId: TicketId,
): Promise<TicketPatientData | null> {
const patientRow = await this.db
.selectFrom('Ticket')
.innerJoin('Patient', (join) =>
join.onRef(sql`"Patient"."id"::uuid`, '=', 'Ticket.patientId'),
)
.select([
'Patient.id',
'Patient.name',
'Patient.surname',
'Patient.birthday',
'Patient.healthinsurance',
'Patient.insuranceNo',
'Patient.careDegree',
'Patient.street',
'Patient.city',
'Patient.zip',
])
.where('Ticket.id', '=', ticketId.value)
.executeTakeFirst();
if (!patientRow) return null;
return TicketPatientData.fromMasterData({
id: new PatientId(patientRow.id),
firstName: patientRow.name,
lastName: patientRow.surname,
birthday: patientRow.birthday,
healthInsurance: patientRow.healthinsurance,
insuranceNo: patientRow.insuranceNo,
careDegree: patientRow.careDegree,
street: patientRow.street,
city: patientRow.city,
zip: patientRow.zip,
});
}
private async loadToursForTicket(
ticketId: TicketId,
): Promise<TicketTourData[]> {
const rows = await this.db
.selectFrom('Tour')
.select([
'id',
'operationId',
'category',
'ordinanceType',
'transportType',
'patientId',
'patientName',
'patientSurname',
'patientStreet',
'patientCity',
'patientZip',
'healthInsurance',
'insuranceNo',
'direction',
'occupiedKm',
'targetStreet',
'targetCity',
'targetZip',
'startStreet',
'startCity',
'startZip',
'startDate',
'rangeEndDate',
'ticketId',
'hasInfection',
'infectionName',
'rangeEndDate',
])
.where('Tour.ticketId', '=', ticketId.value)
.execute();
return rows.map(
(row) =>
new TicketTourData(
new TourId(row.id),
row.operationId,
row.category,
row.ordinanceType,
row.transportType,
row.patientId,
row.patientName,
row.patientSurname,
row.patientStreet,
row.patientCity,
row.patientZip,
row.healthInsurance,
row.insuranceNo,
row.direction,
row.occupiedKm,
row.hasInfection,
row.infectionName,
row.rangeEndDate,
row.targetStreet,
row.targetCity,
row.targetZip,
row.startStreet,
row.startCity,
row.startZip,
row.startDate,
),
);
}
private async loadTransportDocumentForTicket(
ticketId: TicketId,
): Promise<TransportDocument | null> {
const row = await this.db
.selectFrom('TourFile')
.select([
'TourFile.storagePath',
'TourFile.originalFilename',
'TourFile.mimeType',
'TourFile.fileSizeBytes',
])
.where('TourFile.ticketId', '=', ticketId.value)
.limit(1)
.executeTakeFirst();
let transportDocument: TransportDocument | null = null;
if (row) {
transportDocument = new TransportDocument(
row.storagePath,
row.originalFilename,
row.mimeType,
row.fileSizeBytes,
);
}
return transportDocument;
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PatientProvider } from '../domain/ports/patient-provider.interface';
import {
PatientCandidate,
PatientId,
} from '../domain/interfaces/patient-candidate.interface';
import { PatientService } from 'src/modules/feat-business-objects/entities/patient/patient.service';
import { Patient } from '@prisma/client';
@Injectable()
export class LegacyPatientAdapter implements PatientProvider {
constructor(private readonly patientService: PatientService) {}
async findPatientById(id: string): Promise<PatientCandidate | null> {
try {
const patient = await this.patientService.findById(id);
if (!patient) return null;
return this.toPatientCandidate(patient);
} catch (error) {
return null;
}
}
private toPatientCandidate(patient: Patient): PatientCandidate {
return {
id: new PatientId(patient.id),
firstName: patient.name,
lastName: patient.surname,
birthday: patient.birthday,
healthInsurance: patient.healthinsurance,
insuranceNo: patient.insuranceNo,
careDegree: patient.careDegree ?? null,
street: patient.street,
city: patient.city,
zip: patient.zip,
};
}
}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { TourProvider } from '../domain/ports/tour-provider.interface';
import {
TourCandidate,
TourId,
} from '../domain/interfaces/tour-candidate.interface';
import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service';
import { Tour } from '@prisma/client';
import { TicketId } from '../domain/ticket.entity';
@Injectable()
export class LegacyTourAdapter implements TourProvider {
constructor(private readonly tourService: TourService) {}
async findUnticketedTours(): Promise<TourCandidate[]> {
const tours = await this.tourService.findWithoutTicket();
return tours.map((tour) => this.toTourCandidate(tour));
}
async findTourById(id: string): Promise<TourCandidate | null> {
const tour = await this.tourService.findById(id);
return this.toTourCandidate(tour);
}
private toTourCandidate(tour: Tour): TourCandidate {
return {
id: new TourId(tour.id),
operationId: tour.operationId,
category: tour.category,
ordinanceType: tour.ordinanceType,
patientId: tour.patientId,
patientName: tour.patientName,
patientSurname: tour.patientSurname,
patientStreet: tour.patientStreet,
patientCity: tour.patientCity,
patientZip: tour.patientZip,
healthInsurance: tour.healthInsurance,
insuranceNo: tour.insuranceNo,
direction: tour.direction,
targetStreet: tour.targetStreet,
startStreet: tour.startStreet,
startDate: tour.startDate,
rangeEndDate: tour.rangeEndDate,
ticketId: new TicketId(tour.ticketId),
transportType: tour.transportType,
occupiedKm: tour.occupiedKm,
hasInfection: tour.hasInfection,
infectionName: tour.infectionName,
seriesEndDate: tour.rangeEndDate,
targetCity: tour.targetCity,
targetZip: tour.targetZip,
startCity: tour.startCity,
startZip: tour.startZip,
};
}
}
@@ -0,0 +1,137 @@
import { ApprovalRequestStatus } from '@avicenna/shared-dtos';
import { InjectQueue } from '@nestjs/bullmq';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Queue } from 'bullmq';
import { format } from 'date-fns';
import { Ticket, TicketId } from '../../domain/ticket.entity';
import {
TicketApprovalReminderSentEvent,
TicketApprovalRequestedEvent,
} from '../../domain/ticket.events';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../../domain/ticket.repository.interface';
import {
FullRequestJobData,
PreInquiryJobData,
ReminderJobData,
TICKET_EMAIL_QUEUE,
} from '../email/email.job';
@Injectable()
export class TicketApprovalEmailListener {
private readonly logger = new Logger(TicketApprovalEmailListener.name);
constructor(
@InjectQueue(TICKET_EMAIL_QUEUE) private readonly emailQueue: Queue,
@Inject(TICKET_REPOSITORY) private readonly ticketRepo: TicketRepository,
) {}
@OnEvent(TicketApprovalRequestedEvent.name)
async handleApprovalRequested(event: TicketApprovalRequestedEvent) {
const { ticketId, recipientEmail, approvalStatus } = event;
this.logger.log(
`Handling approval request for ${ticketId}, status: ${approvalStatus}`,
);
if(event.approvalStatus === ApprovalRequestStatus.EXTERNAL_REQUEST) {
this.logger.log(`Approval requested externally; doing nothing`)
return;
}
const ticket = await this.ticketRepo.findById(new TicketId(ticketId));
if (!ticket) {
this.logger.error(`Ticket ${ticketId} not found during email generation`);
return;
}
if (approvalStatus === ApprovalRequestStatus.PRE_INQUIRY_SENT) {
await this.queuePreInquiry(ticket, recipientEmail);
} else if (approvalStatus === ApprovalRequestStatus.FULL_REQUEST_SENT) {
await this.queueFullRequest(ticket, recipientEmail);
}
}
@OnEvent(TicketApprovalReminderSentEvent.name)
async handleReminder(event: TicketApprovalReminderSentEvent) {
const ticket = await this.ticketRepo.findById({ value: event.ticketId });
if (!ticket) return;
const replyToId = ticket.approval.fullRequestMessageId;
const recipientEmail = event.recipientEmail;
const payload: ReminderJobData = {
ticketId: ticket.id.value,
recipientEmail,
replyToMessageId: replyToId,
patientName: `${ticket.patient.lastName}, ${ticket.patient.firstName}`,
transportDate: ticket.tours[0]?.startDate,
reminderCount: event.reminderCount,
};
await this.emailQueue.add('send-reminder', payload);
}
// --- PRIVATE WORKERS ---
private async queuePreInquiry(ticket: Ticket, recipientEmail: string) {
const firstTour = ticket.tours.at(0);
// Mapping auf dein Interface
const params = {
patientName: `${ticket.patient.lastName}, ${ticket.patient.firstName}`,
birthday: format(ticket.patient.birthday, 'dd.MM.yyyy') ?? '-',
insuranceNo: ticket.patient.insuranceNo,
healthInsurance: ticket.patient.healthInsurance ?? '',
startAddressLine: firstTour
? `${firstTour.startStreet}, ${firstTour.startZip} ${firstTour.startCity}`
: '',
targetAddressLine: firstTour
? `${firstTour.targetStreet}, ${firstTour.targetZip} ${firstTour.targetCity}`
: '',
transportDate: format(firstTour?.startDate, 'dd.MM.yyyy HH:mm') ?? '-',
};
const payload: PreInquiryJobData = {
ticketId: ticket.id.value,
recipientEmail,
params,
replyToMessageId: undefined, // Startet den Thread
};
this.logger.log(`adding mail to queue`, payload);
const res = await this.emailQueue.add('send-pre-inquiry', payload);
console.log(res);
}
private async queueFullRequest(ticket: Ticket, recipientEmail: string) {
if (!ticket.transportDocument) {
this.logger.warn(
`Ticket ${ticket.id.value} needs full request but has no document.`,
);
return;
}
const replyToId = ticket.approval.preInquiryMessageId;
const payload: FullRequestJobData = {
ticketId: ticket.id.value,
recipientEmail,
replyToMessageId: replyToId,
patientName: `${ticket.patient.lastName}, ${ticket.patient.firstName}`,
attachment: {
storagePath: ticket.transportDocument.storagePath,
originalFilename: ticket.transportDocument.originalFilename,
mimeType: ticket.transportDocument.mimeType,
},
};
const res = await this.emailQueue.add('send-full-request', payload);
console.log(res);
}
}
@@ -0,0 +1,57 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Queue } from 'bullmq';
import { TOUR_FILE_UPLOAD_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import {
TICKET_REPOSITORY,
TicketRepository,
} from '../../domain/ticket.repository.interface';
import { OnEvent } from '@nestjs/event-emitter';
import {
TicketTransportDocumentUploadedEvent,
TourAssignedToTicketEvent,
} from '../../domain/ticket.events';
import { TicketId } from '../../domain/ticket.entity';
@Injectable()
export class UploadSyncListener {
private readonly logger = new Logger(UploadSyncListener.name)
constructor(
@InjectQueue(TOUR_FILE_UPLOAD_QUEUE) private queue: Queue,
@Inject(TICKET_REPOSITORY) private repo: TicketRepository,
) {}
@OnEvent(TicketTransportDocumentUploadedEvent.name)
async handleDocumentUploaded(event: TicketTransportDocumentUploadedEvent) {
this.logger.log(`received TransportDocument; start syncing to Tour`)
const ticketId = new TicketId(event.ticketId);
return await this.addUploadJobForPendingTourFiles(ticketId);
}
@OnEvent(TourAssignedToTicketEvent.name)
async handleTourAssigned(event: TourAssignedToTicketEvent) {
this.logger.log(`Tour added to Ticket; let's see if we have a TransportDocument already and start syncing, if so.`)
const ticketId = new TicketId(event.ticketId);
return await this.addUploadJobForPendingTourFiles(ticketId);
}
private async addUploadJobForPendingTourFiles(
ticketId: TicketId,
): Promise<void> {
const ticket = await this.repo.findById(ticketId);
if (!ticket || !ticket.hasDigitalTransportDocument) {
return;
}
const pendingTourFileIds =
await this.repo.findPendingFilesForTicket(ticketId);
for (const pendingId of pendingTourFileIds) {
await this.queue.add('Transportschein', {
tourFileId: pendingId,
});
}
}
}
@@ -0,0 +1,95 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { MailService } from 'src/core/mail/mail.service';
import { RegisterSentEmailUseCase } from '../../application/register-sent-email.usecase';
import {
FullRequestJobData,
PreInquiryJobData,
ReminderJobData,
TICKET_EMAIL_QUEUE,
TicketEmailJob,
} from '../email/email.job';
@Processor(TICKET_EMAIL_QUEUE)
export class TicketEmailProcessor extends WorkerHost {
private readonly logger = new Logger(TicketEmailProcessor.name);
constructor(
private readonly mailService: MailService,
private readonly registerSentEmailUseCase: RegisterSentEmailUseCase,
) {
super();
}
async process(job: Job<TicketEmailJob, any, string>): Promise<void> {
this.logger.log(
`Processing email job ${job.name}`, job.data
);
try {
switch (job.name) {
case 'send-pre-inquiry': {
const data = job.data as unknown as PreInquiryJobData;
this.logger.log('job data', data)
const result = await this.mailService.sendVorabGenehmigungsanfrage(
data.recipientEmail,
data.params,
data.replyToMessageId,
);
if (result.messageId) {
await this.registerSentEmailUseCase.execute(
data.ticketId,
'PRE',
result.messageId,
);
}
break;
}
case 'send-full-request': {
const data = job.data as unknown as FullRequestJobData;
const result = await this.mailService.sendGenehmigungsanfrage(
data.recipientEmail,
data.attachment,
data.replyToMessageId,
);
if (result.messageId) {
await this.registerSentEmailUseCase.execute(
data.ticketId,
'FULL',
result.messageId,
);
}
break;
}
case 'send-reminder': {
const data = job.data as unknown as ReminderJobData;
await this.mailService.sendReminder(
data.recipientEmail,
{
patientName: data.patientName,
transportDate: new Date(data.transportDate),
reminderCount: data.reminderCount,
},
data.replyToMessageId,
);
break;
}
default:
this.logger.warn(`Unknown job name: ${job.name}`);
}
} catch (error) {
this.logger.error(`Failed to process job ${job.name}`, error);
throw error;
}
}
}
@@ -0,0 +1,67 @@
import { Controller, Get, Post } from '@nestjs/common';
import { ProcessUnticketedToursUseCase } from './application/process-unticketed-tours.usecase';
import { OnEvent } from '@nestjs/event-emitter';
import {
TicketCreatedEvent,
TourAssignedToTicketEvent,
} from './domain/ticket.events';
import { RefreshTicketDataUseCase } from './application/refresh-ticket-data.usecase';
import { CloseInvoicedTickets } from './application/close-invoiced-tickets.usecase';
@Controller('debug/ticketing')
export class TicketDebugController {
private eventStats = {
created: 0,
assigned: 0,
total: 0,
};
constructor(
private processUseCase: ProcessUnticketedToursUseCase,
private refreshUseCase: RefreshTicketDataUseCase,
private closeUseCase: CloseInvoicedTickets,
) {}
@OnEvent(TicketCreatedEvent.name)
handleTicketCreated() {
this.eventStats.created++;
this.eventStats.total++;
}
@OnEvent(TourAssignedToTicketEvent.name)
handleTourAssigned() {
this.eventStats.assigned++;
this.eventStats.total++;
}
@Get('run-batch')
async runBatch() {
console.log('--- Manually triggering Ticket Batch ---');
await this.processUseCase.execute();
return {
message: 'Batch process finished. Check logs.',
stats: this.eventStats,
};
}
@Get('refresh')
async refresh() {
await this.refreshUseCase.execute();
return {
message: 'Refresh finished. Check logs.',
stats: this.eventStats,
};
}
@Get('close')
async close() {
await this.closeUseCase.execute();
return {
message: 'Close finished. Check logs.',
};
}
@Get('stats')
getStats() {
return this.eventStats;
}
}
@@ -1,12 +1,96 @@
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { MailModule } from 'src/core/mail/mail.module';
import { TOUR_FILE_UPLOAD_QUEUE } from '../feat-business-objects/entities/tour/tour.tokens';
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
import { AssignTourToTicketUseCase } from './application/assign-tour.usecase';
import { CreateTicketUseCase } from './application/create-ticket.usecase';
import { MarkAsApprovedUseCase } from './application/mark-as-approved.usecase';
import { OverrideApprovalRequirementUseCase } from './application/override-approval-requirement.usecase';
import { ProcessUnticketedToursUseCase } from './application/process-unticketed-tours.usecase';
import { ReceivePhysicalTransportDocumentUseCase } from './application/receive-physical-transport-document.usecase';
import { RefreshTicketDataUseCase } from './application/refresh-ticket-data.usecase';
import { RegisterSentEmailUseCase } from './application/register-sent-email.usecase';
import { RequestTicketApprovalUseCase } from './application/request-ticket-approval.usecase';
import { SendApprovalReminderUseCase } from './application/send-approval-reminder.usecase';
import { UploadTransportDocumentUseCase } from './application/upload-transport-document.usecase';
import { PATIENT_PROVIDER } from './domain/ports/patient-provider.interface';
import { TOUR_PROVIDER } from './domain/ports/tour-provider.interface';
import { TicketMatchingService } from './domain/services/ticket-matching.service';
import { TICKET_REPOSITORY } from './domain/ticket.repository.interface';
import { TICKET_EMAIL_QUEUE } from './infrastructure/email/email.job';
import { KyselyTicketRepository } from './infrastructure/kysely-ticket.repository';
import { LegacyPatientAdapter } from './infrastructure/legacy-patient.adapter';
import { LegacyTourAdapter } from './infrastructure/legacy-tour.adapter';
import { TicketApprovalEmailListener } from './infrastructure/listener/ticket-approval-email.listener';
import { UploadSyncListener } from './infrastructure/listener/upload-sync.listener';
import { TicketEmailProcessor } from './infrastructure/processor/ticket-email.processor';
import { TicketDebugController } from './ticket-debug.controller';
import { TicketLifecycleService } from './ticket-lifecycle.service';
import { TicketService } from './tickets.service';
import { CloseInvoicedTickets } from './application/close-invoiced-tickets.usecase';
import { RegisterExternalApprovalRequestUseCase } from './application/register-external-approval-request.usecase';
@Module({
imports: [PrismaModule, FeatBusinessObjectsModule],
providers: [TicketService, TicketLifecycleService],
exports: [TicketService, TicketLifecycleService],
imports: [
PrismaModule,
BullModule.registerQueue(
{
name: TOUR_FILE_UPLOAD_QUEUE,
},
{
name: TICKET_EMAIL_QUEUE,
},
),
FeatBusinessObjectsModule,
MailModule,
],
providers: [
TicketService,
TicketLifecycleService,
{
provide: TICKET_REPOSITORY,
useClass: KyselyTicketRepository,
},
{
provide: TOUR_PROVIDER,
useClass: LegacyTourAdapter,
},
{
provide: PATIENT_PROVIDER,
useClass: LegacyPatientAdapter,
},
TicketMatchingService,
UploadSyncListener,
TicketApprovalEmailListener,
TicketEmailProcessor,
AssignTourToTicketUseCase,
CreateTicketUseCase,
ProcessUnticketedToursUseCase,
RefreshTicketDataUseCase,
UploadTransportDocumentUseCase,
OverrideApprovalRequirementUseCase,
RequestTicketApprovalUseCase,
SendApprovalReminderUseCase,
MarkAsApprovedUseCase,
RegisterSentEmailUseCase,
ReceivePhysicalTransportDocumentUseCase,
RegisterExternalApprovalRequestUseCase,
CloseInvoicedTickets,
],
exports: [
TicketService,
TicketLifecycleService,
TICKET_REPOSITORY,
UploadTransportDocumentUseCase,
OverrideApprovalRequirementUseCase,
RequestTicketApprovalUseCase,
SendApprovalReminderUseCase,
MarkAsApprovedUseCase,
ReceivePhysicalTransportDocumentUseCase,
RegisterExternalApprovalRequestUseCase,
],
controllers: [TicketDebugController],
})
export class FeatTicketSystemModule {}
@@ -0,0 +1,50 @@
// timestamp-resolver.spec.ts
import { resolveDateFromTime } from './resolve-timestamp';
describe('resolveDateFromTime', () => {
const TZ_BERLIN = 'Europe/Berlin';
it('should resolve time on the same day correctly', () => {
// Reference: Oct 10th, 14:00 Berlin (12:00 UTC)
const reference = '2023-10-10T12:00:00.000Z';
const time = '14:30';
const result = resolveDateFromTime(reference, time, TZ_BERLIN);
// Expected: Oct 10th, 14:30 Berlin (12:30 UTC)
expect(result.toISOString()).toBe('2023-10-10T12:30:00.000Z');
});
it('should detect day rollover when reference is late and time is early', () => {
// Reference: Oct 10th, 23:50 Berlin (21:50 UTC)
const reference = '2023-10-10T21:50:00.000Z';
const time = '01:35'; // Next morning
const result = resolveDateFromTime(reference, time, TZ_BERLIN);
// Expected: Oct 11th, 01:35 Berlin (Oct 10th, 23:35 UTC)
expect(result.toISOString()).toBe('2023-10-10T23:35:00.000Z');
});
it('should handle boundary cases near midnight on the same day', () => {
// Reference: Oct 10th, 23:00 Berlin
const reference = '2023-10-10T21:00:00.000Z';
const time = '23:59';
const result = resolveDateFromTime(reference, time, TZ_BERLIN);
expect(result.toISOString()).toBe('2023-10-10T21:59:00.000Z');
});
it('should work with a different time zone (e.g. New York)', () => {
const TZ_NY = 'America/New_York';
// Reference: Oct 10th, 12:00 NY (16:00 UTC)
const reference = '2023-10-10T16:00:00.000Z';
const time = '13:00'; // 1 hour later
const result = resolveDateFromTime(reference, time, TZ_NY);
// Expected: Oct 10th, 13:00 NY (17:00 UTC)
expect(result.toISOString()).toBe('2023-10-10T17:00:00.000Z');
});
});

Some files were not shown because too many files have changed in this diff Show More