init data-connector

This commit is contained in:
Marcel Arndt
2025-04-10 17:32:06 +02:00
parent 52c1a14609
commit 46631db5b2
105 changed files with 8081 additions and 338 deletions
@@ -0,0 +1,37 @@
import {
ArgsType,
Field,
Float,
InputType,
Int,
ObjectType,
} from '@nestjs/graphql';
import { PaginationArgs } from 'src/core/base/pagination.input';
import { AccountingFilter } from './accounting-filter';
import { AccountingState } from './accounting-state';
@InputType()
export class AccountingFilterArgs implements AccountingFilter {
@Field(() => [String], { nullable: true })
deviation!: 'pos' | 'neg' | 'both'[];
@Field(() => [String], { nullable: true })
missing?: 'information' | 'documents'[];
// @Field(() => IntervalObjectType, { nullable: true })
// dateRange?: Interval;
}
@ArgsType()
export class AccountingTourArgs extends PaginationArgs {
@Field(() => AccountingState)
state!: AccountingState;
@Field(() => AccountingFilterArgs, { nullable: true })
filters!: AccountingFilterArgs;
}
@ObjectType('TourStateMeta')
export class TourStateMetaObjectType {
@Field(() => Int)
count!: number;
@Field(() => Float, { nullable: true })
revenueSum?: number;
}
@@ -0,0 +1,66 @@
import { Prisma, TicketValidationState } from '@prisma/client';
import { Interval } from 'date-fns';
export interface AccountingFilter {
deviation?: 'pos' | 'neg' | 'both'[];
missing?: 'information' | 'documents'[];
dateRange?: Interval;
}
export function createPrismaQueryFromAccountingFilters(
filter: AccountingFilter,
): Prisma.TourWhereInput[] {
const conditions: Prisma.TourWhereInput[] = [];
Object.entries(filter ?? {}).forEach(([property, args]) => {
switch (property) {
case 'deviation':
if (args.includes('pos'))
conditions.push({
revenueDeviation: { gt: 0 },
});
else if (args.includes('neg'))
conditions.push({
revenueDeviation: { lt: 0 },
});
else if (args.includes('both'))
conditions.push({
revenueDeviation: { not: 0 },
});
return;
case 'missing':
if (args.includes('information'))
conditions.push({
Ticket: {
currentState: {
in: [
TicketValidationState.CREATED,
TicketValidationState.USER_INTERACTION_REQUIRED,
],
},
},
});
else if (args.includes('documents'))
conditions.push({
Ticket: {
currentState: {
in: [
TicketValidationState.TD_UNCERTAIN,
TicketValidationState.DOCUMENTS_MISSING,
],
},
},
});
return;
case 'dateRange':
conditions.push({
AND: [
{ startDate: { gte: new Date(args.from) } },
{ startDate: { lt: new Date(args.to) } },
],
});
return;
}
});
return conditions;
}
@@ -0,0 +1,5 @@
export enum AccountingState {
Billable = 'Billable',
PreparedForBilling = 'PreparedForBilling',
Billed = 'Billed',
}
@@ -0,0 +1,211 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/core/database/prisma.service';
import { TourStateMetaObjectType } from './accounting-arg.types';
import {
AccountingFilter,
createPrismaQueryFromAccountingFilters,
} from './accounting-filter';
import { AccountingState } from './accounting-state';
@Injectable()
export class AccountingValidationRepository {
constructor(private readonly prisma: PrismaService) {}
async findByState(
state: AccountingState,
filter: AccountingFilter,
take?: number,
skip?: number,
) {
const checkValue = this.getCheckValueForAccountingState(state);
const query: Prisma.TourFindManyArgs = {
...{
...(skip ? { skip, take: 10 } : {}),
...(take ? { take } : {}),
...(take && !skip ? { skip: 0 } : {}),
},
where: {
check: checkValue,
...(checkValue === 2 && !filter?.deviation
? { revenueDeviation: { not: 0 } }
: {}),
empty: {
not: null,
},
ticketId: {
not: null,
},
AND: [
...createPrismaQueryFromAccountingFilters(filter),
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
deletedAt: null,
},
{
OR: [
{ patientId: null },
{
AND: [
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
],
},
orderBy: { createdAt: 'desc' },
};
const [tours, count] = await this.prisma.$transaction([
this.prisma.tour.findMany({ ...query }),
this.prisma.tour.count({ where: query.where }),
]);
return {
tours,
total: count,
};
}
async calculateTourStateMeta(
state: AccountingState,
): Promise<TourStateMetaObjectType> {
const checkValue = this.getCheckValueForAccountingState(state);
const ticketWithCurrentStateAndTours: Prisma.TourWhereInput = {
check: checkValue,
...(checkValue === 2 ? { revenueDeviation: { not: 0 } } : {}),
empty: {
not: null,
},
ticketId: {
not: null,
},
AND: [
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
deletedAt: null,
},
{
OR: [
{ patientId: null },
{
AND: [
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
],
};
const [count, revenueSum] = await this.prisma.$transaction([
this.prisma.tour.count({
where: { ...ticketWithCurrentStateAndTours },
}),
this.prisma.tour.aggregate({
_sum: {
revenue: true,
},
where: {
...ticketWithCurrentStateAndTours,
},
}),
]);
return {
count,
revenueSum: revenueSum._sum.revenue ?? 0,
};
}
findTicketByTicketId(ticketId: string) {
return this.prisma.ticket.findFirst({
where: {
id: ticketId,
},
});
}
private getCheckValueForAccountingState(state: AccountingState): number {
switch (state) {
case AccountingState.Billable:
return 0;
case AccountingState.PreparedForBilling:
return 1;
case AccountingState.Billed:
return 2;
}
}
}
@@ -0,0 +1,35 @@
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { TicketObjectType } from '../app-ticket-system/ticket.object-type';
import {
AccountingTourArgs,
TourStateMetaObjectType,
} from './accounting-arg.types';
import { AccountingState } from './accounting-state';
import { AccountingValidationService } from './accounting-validation.service';
import { PaginatedTourObjectType } from './accounting.object-type';
import { TourObjectType } from './tour.object-type';
@Resolver(() => TourObjectType)
export class AccountingValidationResolver {
constructor(private readonly service: AccountingValidationService) {}
@Query(() => PaginatedTourObjectType)
accountingTours(
@Args() { state, filters, cursor, take }: AccountingTourArgs,
) {
return this.service.paginateToursByState(state, filters, take, cursor);
}
@Query(() => TourStateMetaObjectType)
accountingStateMeta(
@Args('state', { type: () => AccountingState })
accountingState: AccountingState,
) {
return this.service.getAccountingStateMeta(accountingState);
}
@ResolveField(() => TicketObjectType, { nullable: true })
ticket(@Parent() tour: TourObjectType) {
return this.service.findTicketByTicketId(tour.ticketId);
}
}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { TourStateMetaObjectType } from './accounting-arg.types';
import { AccountingFilter } from './accounting-filter';
import { AccountingState } from './accounting-state';
import { AccountingValidationRepository } from './accounting-validation.repository';
import { PaginatedTourObjectType } from './accounting.object-type';
import {
decodeOffsetCursor,
encodeCursor,
} from 'src/core/base/pagination.util';
@Injectable()
export class AccountingValidationService {
constructor(private readonly repository: AccountingValidationRepository) {}
async paginateToursByState(
state: AccountingState,
filter: AccountingFilter,
take: number = 10,
cursor?: string,
): Promise<PaginatedTourObjectType> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { tours, total } = await this.repository.findByState(
state,
filter,
take,
skip,
);
const prevPage = skip - take < 0 ? 0 : skip - take;
const nextPage = skip + take < total ? skip + take : skip;
return {
nodes: tours,
prevCursor: encodeCursor(prevPage),
endCursor: encodeCursor(nextPage),
hasNextPage: nextPage !== skip,
totalCount: total,
};
}
async getAccountingStateMeta(
accountingState: AccountingState,
): Promise<TourStateMetaObjectType> {
return this.repository.calculateTourStateMeta(accountingState);
}
findTicketByTicketId(ticketId?: string | null) {
if (!ticketId) return;
return this.repository.findTicketByTicketId(ticketId);
}
}
@@ -0,0 +1,11 @@
import { ObjectType, registerEnumType } from '@nestjs/graphql';
import { AccountingState } from './accounting-state';
import { Paginated } from 'src/core/base/pagination.input';
import { TourObjectType } from './tour.object-type';
@ObjectType('PaginatedTour')
export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
registerEnumType(AccountingState, {
name: 'AccountingState',
});
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { AccountingValidationRepository } from './accounting-validation.repository';
import { AccountingValidationResolver } from './accounting-validation.resolver';
import { AccountingValidationService } from './accounting-validation.service';
@Module({
imports: [PrismaModule],
providers: [
AccountingValidationRepository,
AccountingValidationResolver,
AccountingValidationService,
],
exports: [],
})
export class ApplicationAccountingModule {}
@@ -0,0 +1,110 @@
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
import { Tour } from '@prisma/client';
@ObjectType('Tour')
export class TourObjectType implements Tour {
@Field()
id: string;
@Field()
done: boolean;
@Field()
direction: string;
@Field({ nullable: true })
carName: string | null;
@Field()
operationId: string;
@Field({ nullable: true })
driverId: string | null;
@Field({ nullable: true })
driverName: string | null;
@Field({ nullable: true })
codriverId: string | null;
@Field({ nullable: true })
codriverName: string | null;
@Field({ nullable: true })
patientId: string | null;
@Field({ nullable: true })
patientName: string | null;
@Field({ nullable: true })
patientSurname: string | null;
@Field({ nullable: true })
patientStreet: string | null;
@Field({ nullable: true })
patientZip: string | null;
@Field({ nullable: true })
patientCity: string | null;
@Field({ nullable: true })
healthInsurance: string | null;
@Field({ nullable: true })
healthInsuranceNumber: string | null;
@Field({ nullable: true })
category: string | null;
@Field({ nullable: true })
transportType: string | null;
@Field({ nullable: true })
ordinanceType: string | null;
@Field({ nullable: true })
startInstitution: string | null;
@Field({ nullable: true })
startStreet: string | null;
@Field({ nullable: true })
startZip: string | null;
@Field({ nullable: true })
startCity: string | null;
@Field({ nullable: true })
targetInstitution: string | null;
@Field({ nullable: true })
targetStreet: string | null;
@Field({ nullable: true })
targetZip: string | null;
@Field({ nullable: true })
targetCity: string | null;
@Field({ nullable: true })
startDate: Date;
@Field({ nullable: true })
startBegin: string | null;
@Field({ nullable: true })
startEnd: string | null;
@Field({ nullable: true })
target: string | null;
@Field({ nullable: true })
targetBegin: string | null;
@Field({ nullable: true })
empty: string | null;
@Field(() => Int)
check: number;
@Field(() => Float)
occupiedKm: number | null;
@Field(() => Float)
totalKm: number | null;
@Field()
type: string | null;
@Field({ nullable: true })
rangeEndDate: Date | null;
@Field(() => Float)
revenueDispoLive: number | null;
@Field(() => Float)
revenue: number | null;
@Field(() => Float)
consumptionCosts: number | null;
@Field(() => Float)
revenueDeviation: number;
@Field({ nullable: true })
billDate: Date | null;
@Field({ nullable: true })
billNumber: string | null;
@Field({ nullable: true })
createdAt: Date | null;
@Field({ nullable: true })
deletedAt: Date | null;
@Field(() => Boolean, { nullable: true })
hasInfection: boolean | null;
@Field({ nullable: true })
infectionName: string | null;
@Field({ nullable: true })
ticketId: string | null;
}
@@ -0,0 +1,65 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Anomaly, SolutionTarget, SolutionTargetStep } from '@prisma/client';
import { Paginated } from 'src/core/base/pagination.input';
@ObjectType('SolutionTarget')
export class SolutionTargetObjectType implements SolutionTarget {
@Field()
id: string;
@Field()
name: string;
@Field()
anomalyId: string;
}
@ObjectType('SolutionTargetStep')
export class SolutionTargetStepObjectType implements SolutionTargetStep {
@Field()
id: string;
@Field()
description: string;
@Field()
solutionTargetId: string;
}
@ObjectType('Anomaly')
export class AnomalyObjectType implements Anomaly {
@Field()
id: string;
@Field()
type: string;
@Field()
groupKey: string;
@Field()
groupDescription: string;
@Field()
workedOnBy: string;
@Field(() => Int)
sleepTimer: number;
@Field({ nullable: true })
sleepSince: Date | null;
@Field()
description: string;
@Field({ nullable: true })
resolvedAt: Date | null;
@Field({ nullable: true })
manuallyResolved: boolean | null;
@Field()
createdAt: Date;
}
@ObjectType('PaginatedAnomaly')
export class PaginatedAnomalyObjectType extends Paginated(AnomalyObjectType) {}
@ObjectType('AnomalyGroup')
export class AnomalyGroupObjectType {
@Field()
key: string;
@Field(() => [AnomalyObjectType])
anomalies: AnomalyObjectType[];
}
@ObjectType('PaginatedAnomalyGroup')
export class PaginatedAnomalyGroupObjectType extends Paginated(
AnomalyGroupObjectType,
) {}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ControlCenterResolver } from './control-center.resolver';
import { SolutionTargetsResolver } from './solution-targets.resolver';
import { AnomalyDetectionModule } from '../feat-anomalies/anomaly-detection.module';
@Module({
imports: [AnomalyDetectionModule],
providers: [ControlCenterResolver, SolutionTargetsResolver],
})
export class ApplicationControlCenterModule {}
@@ -0,0 +1,108 @@
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PaginationArgs } from 'src/core/base/pagination.input';
import {
decodeOffsetCursor,
encodeCursor,
} from 'src/core/base/pagination.util';
import { TourObjectType } from '../app-accounting/tour.object-type';
import { AnomaliesService } from '../feat-anomalies/anomalies.service';
import { AnomalyTypes } from '../feat-anomalies/anomaly-detectors/anomaly-types.enum';
import {
AnomalyObjectType,
PaginatedAnomalyGroupObjectType,
PaginatedAnomalyObjectType,
SolutionTargetObjectType,
} from './anomaly.object-type';
@Resolver(() => AnomalyObjectType)
export class ControlCenterResolver {
constructor(private readonly anomaliesService: AnomaliesService) {}
@Query(() => AnomalyObjectType)
async anomaly(@Args('id') id: string) {
return this.anomaliesService.findById(id);
}
@Query(() => [AnomalyObjectType])
async anomaliesProto() {
return this.anomaliesService.findActiveAnomalies();
}
@Query(() => PaginatedAnomalyObjectType)
async anomalies(@Args() { cursor, take }: PaginationArgs) {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const activeAnomalies = await this.anomaliesService.findActiveAnomalies(
skip,
take,
[AnomalyTypes.WorkingEmployeeNotInDistributionSystem],
);
const totalCount = await this.anomaliesService.countActiveAnomalies([
AnomalyTypes.WorkingEmployeeNotInDistributionSystem,
]);
const prevPage = skip - take < 0 ? 0 : skip - take;
const nextPage = skip + take < totalCount ? skip + take : skip;
return {
nodes: activeAnomalies,
prevCursor: encodeCursor(prevPage),
endCursor: encodeCursor(nextPage),
hasNextPage: nextPage !== skip,
totalCount,
};
}
@Query(() => PaginatedAnomalyGroupObjectType)
async groupedAnomalies(@Args() { cursor, take }: PaginationArgs) {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { totalCount, anomalies: groupedActiveAnomalies } =
await this.anomaliesService.findActiveAnomaliesGroupedByGroupKey(
skip,
take,
);
const prevPage = skip - take < 0 ? 0 : skip - take;
const nextPage = skip + take < totalCount ? skip + take : skip;
return {
nodes: groupedActiveAnomalies,
prevCursor: encodeCursor(prevPage),
endCursor: encodeCursor(nextPage),
hasNextPage: nextPage !== skip,
totalCount,
};
}
@Mutation(() => AnomalyObjectType)
async setAnomalyToSleep(@Args({ name: 'id' }) anomalyId: string) {
return this.anomaliesService.setAnomalyToSleep(anomalyId);
}
@Mutation(() => AnomalyObjectType)
async acceptAnomaly(@Args({ name: 'id' }) anomalyId: string) {
return this.anomaliesService.acceptAnomaly(anomalyId);
}
@ResolveField(() => TourObjectType, { nullable: true })
tour(@Parent() anomaly: AnomalyObjectType) {
const [operationId] = anomaly.groupKey.split('-').reverse();
return this.anomaliesService.findTourByOperationId(operationId);
}
@ResolveField(() => [SolutionTargetObjectType])
solution(
@Parent() anomaly: AnomalyObjectType,
): Promise<SolutionTargetObjectType[]> {
return this.anomaliesService.findSolutionTargetsByAnomalyId(anomaly.id);
}
}
@@ -0,0 +1,21 @@
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { AnomaliesService } from '../feat-anomalies/anomalies.service';
import {
SolutionTargetObjectType,
SolutionTargetStepObjectType,
} from './anomaly.object-type';
@Resolver(() => SolutionTargetObjectType)
export class SolutionTargetsResolver {
constructor(private readonly anomaliesService: AnomaliesService) {}
@ResolveField(() => [SolutionTargetStepObjectType!]!)
steps(
@Parent() solutionTarget: SolutionTargetObjectType,
): Promise<SolutionTargetStepObjectType[]> {
return this.anomaliesService.findSolutionTargetStepsBySolutionTargetId(
solutionTarget.id,
);
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { FeatureCalculationModule } from '../feat-calculation/feat-calculation.module';
import { TourAmountForecastService } from './tour-amount-forecast.service';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
import { ManagerResolver } from './manager.resolver';
import { CarEntityService } from './car-entity/car-entity.service';
import { CarEntityRepository } from './car-entity/car-entity.repository';
@Module({
imports: [PrismaModule, FeatureCalculationModule],
providers: [
TourAmountForecastService,
TimePerTourKpiService,
TimePerTourMetricsService,
CarEntityService,
CarEntityRepository,
ManagerResolver,
],
})
export class ApplicationManagerModule {}
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { PlannedTime, Tour } from '@prisma/client';
import { Interval } from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service';
@Injectable()
export class CarEntityRepository {
constructor(private readonly prisma: PrismaService) {}
findToursInInterval(interval: Interval): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
check: {
not: 3,
},
},
{
deletedAt: null,
},
{
OR: [
{
patientId: null,
},
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
findPlannedTimeByOrdinanceTypesInInterval(
ordinanceType: string,
interval: Interval,
): Promise<PlannedTime[]> {
return this.prisma.plannedTime.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lte: new Date(interval.end),
},
},
{
AND: [
{
department: {
contains: ordinanceType,
},
},
{
department: {
not: 'Leitstelle',
},
},
{
department: {
not: 'Büro',
},
},
],
},
{
note: null,
},
{
userId: {
not: '0',
},
},
],
},
});
}
}
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { PlannedTime, Tour } from '@prisma/client';
import { CarEntityRepository } from './car-entity.repository';
import { CarEntity } from './car.entity';
import { Interval } from 'date-fns';
import { generateIntervalForTimeRange } from 'src/utils';
@Injectable()
export class CarEntityService {
constructor(private readonly repository: CarEntityRepository) {}
async getCarsInInterval(
interval: Interval,
ordinanceType: string,
): Promise<CarEntity[]> {
const carEntities = await this.generateCarEntitiesForInterval(
interval,
ordinanceType,
);
return carEntities;
}
async getCarsForOperationDay(
day: Date,
ordinanceType: string,
): Promise<CarEntity[]> {
const operationDayInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
day,
);
const carEntities = await this.generateCarEntitiesForInterval(
operationDayInterval,
ordinanceType,
);
return carEntities;
}
private async generateCarEntitiesForInterval(
interval: Interval,
ordinanceType: string,
): Promise<CarEntity[]> {
const toursInFullRange =
await this.repository.findToursInInterval(interval);
const plannedTimeByOrdinanceType =
await this.repository.findPlannedTimeByOrdinanceTypesInInterval(
ordinanceType,
interval,
);
return this.generateCarEntities(
toursInFullRange,
plannedTimeByOrdinanceType,
ordinanceType,
);
}
private generateCarEntities(
tours: Tour[],
plannedTimes: PlannedTime[],
ordinanceType: string,
): CarEntity[] {
const departmentsAsCarNames = [
...new Set(plannedTimes.map(({ department }) => department)),
];
const toursWithoutCarName = tours
.filter((tour) => tour?.ordinanceType?.includes(ordinanceType))
.filter(({ carName }) => !carName);
return [
...departmentsAsCarNames.map((departmentAsCarName) => {
const toursForCarName = tours.filter(({ carName }) =>
this.isCarNameForDepartment(departmentAsCarName, carName),
);
const plannedTimesForCarName = plannedTimes.filter(
({ department }) => department === departmentAsCarName,
);
return new CarEntity(
toursForCarName,
plannedTimesForCarName,
departmentAsCarName,
ordinanceType,
);
}),
...(toursWithoutCarName.length
? [new CarEntity(toursWithoutCarName, [], undefined, ordinanceType)]
: []),
];
}
private isCarNameForDepartment(department: string, carName: string): boolean {
if (!department || !carName) return false;
const [ordinanceType, carNumber] = department
.replace('1/', '')
.replace('E', '')
.split('-')
.map((part) => part.trim());
return (
(carName.indexOf(ordinanceType) > -1 &&
carName.indexOf(carNumber) > -1) ||
carName === department
);
}
}
@@ -0,0 +1,160 @@
import { PlannedTime, Tour } from '@prisma/client';
import {
areIntervalsOverlapping,
endOfDay,
format,
Interval,
isWithinInterval,
startOfDay,
} from 'date-fns';
import { CalculationService } from 'src/modules/feat-calculation/calculation.service';
import { clampInterval, getHoursInInterval } from 'src/utils';
const EMPLOYEE_AMOUNT_BY_ORDINANCE_TYPE = {
BTW: 1,
TSW: 2,
KTW: 2,
};
export class CarEntity {
constructor(
private readonly tours: Tour[],
private readonly plannedTimes: PlannedTime[],
public readonly carName: string,
private readonly ordinanceType: string,
) {}
availableTimeInInterval(interval: Interval): number {
return (
this.plannedTimes
.map(({ startDate, endDate }) => ({
start: new Date(startDate),
end: new Date(endDate),
}))
.filter((plannedTimeInterval) =>
areIntervalsOverlapping(plannedTimeInterval, interval),
)
.map((plannedTimeInterval) =>
getHoursInInterval(plannedTimeInterval, interval),
)
.sort()
.at(0) ?? 0
);
}
tourAmountInInterval(interval: Interval): number {
return this.toursInInterval(interval).reduce(
(sum, { occupiedKm }) => (sum += occupiedKm > 30 ? occupiedKm / 30 : 1),
0,
);
}
toursInInterval(interval: Interval): Tour[] {
return this.tours
.filter(({ startDate }) =>
isWithinInterval(new Date(startDate), interval),
)
.filter(({ occupiedKm }) => occupiedKm < 9999);
}
async breakEvenInInterval(
interval: Interval,
calculationService: CalculationService,
): Promise<{
toursRevenue;
personnelCosts;
consumptionCost;
}> {
const toursInInterval = this.toursInInterval(interval);
const toursRevenue = toursInInterval.reduce(
(sum, tour) => (sum += tour.revenue),
0,
);
const consumptionCost = toursInInterval.reduce(
(sum, tour) => (sum += tour.consumptionCosts),
0,
);
const plannedTimeIntervalInInterval =
this.plannedTimeIntervalInInterval(interval);
if (!plannedTimeIntervalInInterval) {
return {
toursRevenue,
personnelCosts: 0,
consumptionCost,
};
}
const personnelCosts =
calculationService.calculatePersonnelCostInInterval(
clampInterval(plannedTimeIntervalInInterval, interval),
this.employeeAmount,
this.ordinanceType,
) / 100;
return {
toursRevenue,
personnelCosts,
consumptionCost,
};
}
getTourAmountAtDateCreatedToday(date: Date): number {
const dateInterval: Interval = {
start: startOfDay(date),
end: endOfDay(date),
};
const todayInterval: Interval = {
start: startOfDay(new Date()),
end: endOfDay(new Date()),
};
return this.tours.filter(
({ startDate, createdAt }) =>
isWithinInterval(new Date(startDate), dateInterval) &&
isWithinInterval(new Date(createdAt), todayInterval),
).length;
}
toString(): string {
return `${this.carName}
${this.tours.length} Touren
${this.plannedTimes.length} geplante Mitarbeiter
${
this.plannedTimes.length
? this.plannedTimes
.map(
({ startDate, endDate }) =>
`${format(new Date(startDate), 'HH:mm')}-${format(
new Date(endDate),
'HH:mm',
)}`,
)
.join(', ')
: ''
}`;
}
private plannedTimeIntervalInInterval(interval: Interval): Interval {
return this.plannedTimes
.map(({ startDate, endDate }) => ({
start: new Date(startDate),
end: new Date(endDate),
}))
.filter((plannedTimeInterval) =>
areIntervalsOverlapping(plannedTimeInterval, interval),
)
.at(0);
}
private hasPlannedTimeInInterval(interval: Interval): boolean {
return !!this.plannedTimeIntervalInInterval(interval);
}
private get employeeAmount(): number {
return EMPLOYEE_AMOUNT_BY_ORDINANCE_TYPE[this.ordinanceType];
}
}
@@ -0,0 +1,86 @@
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
import { Interval } from 'date-fns';
import { IntervalObjectType } from 'src/core/base/interval.object-type';
@ObjectType('KPI')
export class KPIObjectType {
@Field(() => Float)
value: number;
@Field(() => KPIMetadataObjectType)
meta: {
tourAmount: number;
availableTime: number;
baseValue: number;
breakEven: number;
};
}
@ObjectType('KPIByCar')
export class KPIByCarObjectType {
@Field()
carName: string;
@Field(() => KPIObjectType)
kpi: KPIObjectType;
}
@ObjectType('TimePerTourKPISummary')
export class TimePerTourKPISummaryObjectType {
@Field(() => IntervalObjectType)
interval: Interval;
@Field(() => KPIObjectType)
kpi: KPIObjectType;
@Field(() => [KPIByCarObjectType])
cars: KPIByCarObjectType[];
}
@ObjectType('KPIMetadata')
export class KPIMetadataObjectType {
@Field(() => Float)
tourAmount: number;
@Field(() => Float)
availableTime: number;
@Field(() => Float)
baseValue: number;
@Field(() => Float)
breakEven: number;
}
@ObjectType('TimePerTourUpdate')
export class TimePerTourUpdateObjectType {
@Field()
job: string;
}
@ObjectType('TimePerTourMetric')
export class TimePerTourMetricObjectType {
@Field(() => IntervalObjectType)
interval: Interval;
@Field(() => Float)
availableTime: number;
@Field(() => Float)
tourAmount: number;
@Field(() => Float)
breakEven: number;
@Field(() => Float)
revenue: number;
@Field(() => Float)
averageRevenue: number;
@Field(() => Float)
personnelCost: number;
@Field(() => Float)
consumptionCost: number;
@Field(() => Float)
forecast: number;
@Field(() => Float)
forecastRevenue: number;
@Field(() => [TimePerTourMetricCategoryAmountObjectType])
categoryAmounts: TimePerTourMetricCategoryAmountObjectType[];
}
@ObjectType('TimePerTourMetricCategoryAmount')
export class TimePerTourMetricCategoryAmountObjectType {
@Field()
category: string;
@Field(() => Int)
amount: number;
}
@@ -0,0 +1,46 @@
import { Args, Query, Resolver, Subscription } from '@nestjs/graphql';
import {
TimePerTourKPISummaryObjectType,
TimePerTourMetricObjectType,
TimePerTourUpdateObjectType,
} from './manager.object-type';
import { TimePerTourKpiService } from './time-per-tour-kpi.service';
import { TimePerTourMetricsService } from './time-per-tour-metrics.service';
@Resolver()
export class ManagerResolver {
constructor(
private readonly timePerTourKpiService: TimePerTourKpiService,
private readonly timePerTourMetricService: TimePerTourMetricsService,
) {}
@Query(() => [TimePerTourKPISummaryObjectType])
toursPerTimeKPI(
@Args('ordinanceTypes', { type: () => String }) ordinanceType: string,
) {
return this.timePerTourKpiService.toursPerTimeKPI(ordinanceType);
}
@Query(() => [TimePerTourMetricObjectType])
timePerTourMetricForDay(
@Args('day', { type: () => Date }) day: Date,
@Args('ordinanceType', { type: () => String }) ordinanceType: string,
) {
return this.timePerTourMetricService.forDay(day, ordinanceType);
}
@Query(() => [TimePerTourMetricObjectType])
timePerTourMetricForMonth(
@Args('day', { type: () => Date }) day: Date,
@Args('ordinanceType', { type: () => String }) ordinanceType: string,
) {
return this.timePerTourMetricService.forMonth(day, ordinanceType);
}
@Subscription(() => TimePerTourUpdateObjectType)
timePerTourUpdated() {
return this.timePerTourKpiService.pubSub.asyncIterator(
'timePerTourUpdated',
);
}
}
@@ -0,0 +1,145 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { addMilliseconds } from 'date-fns';
import { PubSub } from 'graphql-subscriptions';
import { generateIntervalForTimeRange } from 'src/utils';
import { CarEntityService } from './car-entity/car-entity.service';
import { CalculationService } from '../feat-calculation/calculation.service';
import { ETLEventTypes } from '../feat-etl/feat-importers/events/event-types.enum';
import { LoadingDoneEvent } from '../feat-etl/feat-importers/events/loading-done.event';
@Injectable()
export class TimePerTourKpiService {
public readonly pubSub = new PubSub();
@OnEvent(ETLEventTypes.LoadingDone, { async: true })
notifyOnPlannedTimeUpdate(event: LoadingDoneEvent): void {
this.pubSub.publish('timePerTourUpdated', {
timePerTourUpdated: { job: event.jobId },
});
}
constructor(
private readonly calculationService: CalculationService,
private readonly carEntityService: CarEntityService,
) {}
async toursPerTimeKPI(ordinanceType: string, date = new Date()) {
const fullRangeInterval = generateIntervalForTimeRange(
'05:00-01:00',
'Europe/Berlin',
date,
);
const subIntervals = [
generateIntervalForTimeRange('05:00-12:00', 'Europe/Berlin'),
generateIntervalForTimeRange('12:00-18:30', 'Europe/Berlin'),
generateIntervalForTimeRange('18:30-01:00', 'Europe/Berlin'),
];
const carEntities = await this.carEntityService.getCarsInInterval(
fullRangeInterval,
ordinanceType,
);
return Promise.all(
subIntervals
.map(({ start, end }, index, array) => ({
start,
end: index + 1 !== array.length ? addMilliseconds(end, -1) : end,
}))
.map(async (interval) => {
const cars = await Promise.all(
carEntities.map(async (carEntity) => {
const tourAmount = carEntity.tourAmountInInterval(interval);
const availableTime = carEntity.availableTimeInInterval(interval);
const { personnelCosts, consumptionCost, toursRevenue } =
await carEntity.breakEvenInInterval(
interval,
this.calculationService,
);
const baseValue = this.calculateKPIBaseValue(
availableTime,
tourAmount,
);
return {
carName: carEntity.carName ?? 'Touren ohne Zuweisung',
kpi: {
value: !carEntity.carName ? tourAmount : baseValue,
meta: {
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
baseValue,
breakEven:
Math.round(
(toursRevenue - personnelCosts - consumptionCost) * 100,
) / 100,
},
},
};
}),
);
const tourAmount = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.tourAmountInInterval(interval)),
0,
);
const availableTime = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.availableTimeInInterval(interval)),
0,
);
let revenue = 0;
let personnelCost = 0;
let consumptionCost = 0;
for (const carEntity of carEntities) {
const {
personnelCosts,
consumptionCost: cc,
toursRevenue,
} = await carEntity.breakEvenInInterval(
interval,
this.calculationService,
);
revenue += toursRevenue;
personnelCost += personnelCosts;
consumptionCost += cc;
}
const baseValue = this.calculateKPIBaseValue(
availableTime,
tourAmount,
);
return {
interval,
kpi: {
value: baseValue,
meta: {
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
baseValue,
breakEven:
Math.round(
(revenue - personnelCost - consumptionCost) * 100,
) / 100,
},
},
cars,
};
}),
);
}
calculateKPIBaseValue(availableTime: number, tourAmount: number): number {
if (tourAmount === 0 && availableTime > 0) {
return Math.round((availableTime / 0.01) * 1000) / 1000;
} else if (tourAmount === 0) {
return -1;
}
return Math.round((availableTime / tourAmount) * 1000) / 1000;
}
}
@@ -0,0 +1,214 @@
import { Injectable } from '@nestjs/common';
import {
addDays,
addMilliseconds,
areIntervalsOverlapping,
differenceInCalendarDays,
eachDayOfInterval,
eachHourOfInterval,
endOfDay,
endOfMonth,
getISODay,
Interval,
isSunday,
set,
startOfDay,
startOfMonth,
} from 'date-fns';
import { CarEntityService } from './car-entity/car-entity.service';
import { TourAmountForecastService } from './tour-amount-forecast.service';
import { TimePerTourMetricObjectType } from './manager.object-type';
import { CalculationService } from '../feat-calculation/calculation.service';
import { generateIntervalForTimeRange, groupBy } from 'src/utils';
@Injectable()
export class TimePerTourMetricsService {
constructor(
private readonly calculationService: CalculationService,
private readonly tourAmountForecastService: TourAmountForecastService,
private readonly carEntityService: CarEntityService,
) {}
async forMonth(date: Date, ordinanceType: string) {
const fullRangeInterval = {
start: startOfMonth(date),
end: endOfMonth(date),
};
const subIntervals = eachDayOfInterval(fullRangeInterval)
.filter((day) => !isSunday(day))
.reduce(
(subIntervals, day) => [
...subIntervals,
generateIntervalForTimeRange('05:00-04:59', 'Europe/Berlin', day),
],
[] as Interval[],
);
return this.generateTimePerTourMetric(
fullRangeInterval,
subIntervals,
ordinanceType,
);
}
async forDay(date: Date, ordinanceType: string) {
const fullRangeInterval = generateIntervalForTimeRange(
'05:00-04:59',
'Europe/Berlin',
date,
);
const subIntervals = eachHourOfInterval(fullRangeInterval)
.reduce(
(subIntervals, hour, index, array) => [
...subIntervals,
...(index === array.length - 1
? []
: [{ start: hour, end: array[index + 1] }]),
],
[] as Interval[],
)
.map(({ start, end }, index, array) => ({
start,
end: index + 1 !== array.length ? addMilliseconds(end, -1) : end,
}));
return this.generateTimePerTourMetric(
fullRangeInterval,
subIntervals,
ordinanceType,
);
}
private async generateTimePerTourMetric(
intervalClamp: Interval,
subIntervals: Interval[],
ordinanceType: string,
): Promise<TimePerTourMetricObjectType[]> {
const carEntities = await this.carEntityService.getCarsInInterval(
intervalClamp,
ordinanceType,
);
const forecastPeriod: Interval = {
start: startOfDay(new Date()),
end: endOfDay(addDays(new Date(), 7)),
};
const forecastForOrdinanceType =
await this.calculateForecastForOrdinanceType(
forecastPeriod,
intervalClamp,
ordinanceType,
);
return Promise.all(
subIntervals.map(async (interval) => {
const tourAmount = carEntities.reduce(
(sum, carEntity) => (sum += carEntity.tourAmountInInterval(interval)),
0,
);
const availableTime = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.availableTimeInInterval(interval)),
0,
);
let revenue = 0;
let personnelCost = 0;
let consumptionCost = 0;
const categoryAmounts = Object.entries(
groupBy(
carEntities.flatMap((ce) => ce.toursInInterval(interval)),
({ category }) => category,
),
).reduce(
(sum, [category, tours]) => [
...sum,
{ category, amount: tours.length },
],
[],
);
for (const carEntity of carEntities) {
const {
personnelCosts,
consumptionCost: cc,
toursRevenue,
} = await carEntity.breakEvenInInterval(
interval,
this.calculationService,
);
revenue += toursRevenue;
personnelCost += personnelCosts;
consumptionCost += cc;
}
let forecast = 0;
let forecastRevenue = 0;
if (areIntervalsOverlapping(interval, forecastPeriod)) {
const dayDiffFromNow = differenceInCalendarDays(
interval.start,
new Date(),
);
const weekday = getISODay(interval.start);
const tourAmountAtDateCreatedToday = carEntities.reduce(
(sum, carEntity) =>
(sum += carEntity.getTourAmountAtDateCreatedToday(
new Date(interval.start),
)),
0,
);
forecast =
forecastForOrdinanceType[weekday]
?.slice(0, dayDiffFromNow + 1)
.reduce((sum, tourAmount) => (sum += tourAmount), 0) -
(tourAmountAtDateCreatedToday ?? 0);
if (forecast >= 0) {
const billingInformation =
this.calculationService.getModellBillingInformationByOrdinanceType(
ordinanceType,
set(new Date(), {
hours: 13,
minutes: 0,
seconds: 0,
milliseconds: 0,
}),
);
forecastRevenue =
billingInformation.revenuePerTour * Math.round(forecast) -
billingInformation.consumptionCostsPerTour * Math.round(forecast);
}
}
return {
interval,
tourAmount: Math.round(tourAmount * 100) / 100,
availableTime: Math.round(availableTime * 100) / 100,
breakEven:
Math.round((revenue - personnelCost - consumptionCost) * 100) / 100,
revenue,
averageRevenue: isNaN(revenue / tourAmount)
? 0
: revenue / tourAmount,
personnelCost,
consumptionCost,
forecast: forecast < 0 || isNaN(forecast) ? 0 : forecast,
forecastRevenue,
categoryAmounts,
};
}),
);
}
private async calculateForecastForOrdinanceType(
forecastPeriod: Interval,
requestedInterval: Interval,
ordinanceType: string,
): Promise<Record<string, number[]>> {
if (areIntervalsOverlapping(requestedInterval, forecastPeriod)) {
return this.tourAmountForecastService.calculateTourAmountForecastFourWeekPeriod(
ordinanceType,
);
}
return {};
}
}
@@ -0,0 +1,243 @@
import { Injectable } from '@nestjs/common';
import { Tour } from '@prisma/client';
import {
addDays,
differenceInCalendarDays,
eachDayOfInterval,
endOfDay,
format,
getISODay,
Interval,
isSameDay,
startOfDay,
} from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service';
@Injectable()
export class TourAmountForecastService {
constructor(private readonly prisma: PrismaService) {}
calculateTourAmountForecastFourWeekPeriod(
ordinanceType: string | undefined,
): Promise<Record<string, number[]>> {
return this.calculateTourAmountForecastForInterval(
{
start: startOfDay(addDays(new Date(), -28)),
end: endOfDay(new Date()),
},
ordinanceType,
);
}
async calculateTourAmountForecastForInterval(
interval: Interval,
ordinanceType: string | undefined,
): Promise<Record<string, number[]>> {
const toursInInterval = await this.findToursInInterval(
interval,
ordinanceType,
);
const toursGroupedByDay = this.groupToursByDay(interval, toursInInterval);
const tourCreationToStartDiffByDay = Object.entries(
toursGroupedByDay,
).reduce(
(group, [dateString, tours]) => ({
...group,
[dateString]: this.calculateCreationToStartDifferenceOfTours(tours),
}),
{} as Record<string, Record<string, number>>,
);
const tourCreationToStartDiffsByWeekday = Object.entries(
tourCreationToStartDiffByDay,
).reduce(
(group, [dateString, diffs]) => {
const weekday = getISODay(new Date(dateString));
const existingDiffs = group[weekday] ?? {};
const dayDifferences = [0, 1, 2, 3, 4, 5, 6, 7];
const diffsCombined = dayDifferences.reduce(
(combined, dayDifference) => {
const diffForDay = diffs[dayDifference];
return {
...combined,
[dayDifference]: [
...(existingDiffs[dayDifference] ?? []),
diffForDay,
],
};
},
{} as Record<string, number[]>,
);
return {
...group,
[weekday]: diffsCombined,
};
},
{} as Record<string, Record<string, number[]>>,
);
const tourAmountForecastByWeekday = Object.entries(
tourCreationToStartDiffsByWeekday,
).reduce(
(group, [weekday, diffs]) => {
const diffsMedian = Object.entries(diffs).reduce(
(combined, [, tourAmounts]) => {
const median =
tourAmounts.reduce(
(sum, tourAmount) => (sum += tourAmount),
0.0,
) / tourAmounts.length;
return [...combined, median];
},
[] as number[],
);
return {
...group,
[weekday]: diffsMedian,
};
},
{} as Record<string, number[]>,
);
return tourAmountForecastByWeekday;
}
private groupToursByDay(
interval: Interval,
tours: Tour[],
): Record<string, Tour[]> {
const daysInInterval = eachDayOfInterval(interval);
return daysInInterval.reduce(
(group, day) => {
return {
...group,
[format(day, 'yyyy-MM-dd')]: tours.filter(({ startDate }) =>
isSameDay(day, new Date(startDate)),
),
};
},
{} as Record<string, Tour[]>,
);
}
private calculateCreationToStartDifferenceOfTours(
tours: Tour[],
): Record<string, number> {
return tours.reduce(
(diffs, tour) => {
const tourCreationToStartDiff = differenceInCalendarDays(
new Date(tour.startDate),
new Date(tour.createdAt),
);
if (tourCreationToStartDiff > 7 || tourCreationToStartDiff < 0) {
return diffs;
}
return {
...diffs,
[tourCreationToStartDiff]: (diffs[tourCreationToStartDiff] ?? 0) + 1,
};
},
{ 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0 } as Record<
string,
number
>,
);
}
private findToursInInterval(
interval: Interval,
ordinanceType: string | undefined,
) {
const ordinanceTypeToQuery = ordinanceType.split(' ')[0];
return this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
...(ordinanceType
? [
{
AND: [
{
ordinanceType: ordinanceTypeToQuery,
},
...(ordinanceType.indexOf('TSW') > -1
? [
{
transportType:
ordinanceType.indexOf('1') > -1 ? 'TS1' : 'TS2',
},
]
: []),
],
},
]
: []),
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
check: {
not: 3,
},
},
{
deletedAt: null,
},
{
OR: [
{
patientId: null,
},
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { TicketLifecycleService } from './ticket-lifecycle.service';
import { TicketRepository } from './ticket.repository';
import { TicketsResolver } from './tickets.resolver';
import { TicketService } from './tickets.service';
@Module({
imports: [PrismaModule],
providers: [
TicketRepository,
TicketService,
TicketLifecycleService,
TicketsResolver,
],
exports: [TicketService],
})
export class ApplicationTicketSystemModule {}
@@ -0,0 +1,42 @@
import { ArgsType, Field, InputType } from '@nestjs/graphql';
import { ApprovalState, TicketValidationState } from '@prisma/client';
import { PaginationArgs } from 'src/core/base/pagination.input';
import { TicketFilter } from './ticket-filter';
@InputType()
export class TicketFilterArgs implements TicketFilter {
@Field(() => [String], { nullable: true })
errors: string[];
@Field(() => [ApprovalState], { nullable: true })
approvalStates: ApprovalState[];
@Field(() => [String], { nullable: true })
documentInfo: ('approval' | 'transport_document')[];
}
@ArgsType()
export class TicketsArgs extends PaginationArgs {
@Field(() => TicketValidationState)
state: TicketValidationState;
@Field(() => TicketFilterArgs, { nullable: true })
filters: TicketFilterArgs;
}
@InputType()
export class ApprovalInfoArgs {
@Field({ nullable: true })
isNeeded?: boolean;
@Field({ nullable: true })
whoRequested?: string;
@Field({ nullable: true })
answeredFrom?: string;
@Field({ nullable: true })
location?: string;
}
@ArgsType()
export class DocumentInfoArgs {
@Field(() => ApprovalInfoArgs, { nullable: true })
approval: ApprovalInfoArgs;
@Field({ nullable: true })
tdLocation?: string;
}
@@ -0,0 +1,71 @@
import { ApprovalState, Prisma } from '@prisma/client';
export interface TicketFilter {
errors?: string[];
approvalStates?: ApprovalState[];
documentInfo?: ('approval' | 'transport_document')[];
}
export function createPrismaQueryFromTicketFilters(
filter: TicketFilter,
): Prisma.TicketWhereInput[] {
const conditions: Prisma.TicketWhereInput[] = [];
Object.entries(filter ?? {}).forEach(([property, args]) => {
switch (property) {
case 'errors':
if (args.length > 0)
conditions.push({
errors: {
hasSome: [...args],
},
});
return;
case 'approvalStates':
if (args.length > 0)
conditions.push({
approvalState: {
in: [...args],
},
});
return;
case 'documentInfo':
if (args.includes('approval')) {
conditions.push({
OR: [
{
documentInfo: {
path: ['approval', 'location'],
not: 'company',
},
},
{
documentInfo: {
path: ['approval', 'location'],
equals: Prisma.JsonNull,
},
},
],
});
}
if (args.includes('td_company')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'company',
},
});
}
if (args.includes('td_customer')) {
conditions.push({
documentInfo: {
path: ['tdLocation'],
equals: 'customer',
},
});
}
return;
}
});
return conditions;
}
@@ -0,0 +1,240 @@
import { Injectable, Logger } from '@nestjs/common';
import {
ApprovalState,
Ticket,
TicketValidationState,
Tour,
} from '@prisma/client';
import { Interval } from 'date-fns';
import { DocumentInfoArgs } from './ticket-arg.types';
import {
ValidationResult,
validatePatient,
validatePatientDataOnTours,
validateTour,
} from './ticket-validation.functions';
import { TicketRepository } from './ticket.repository';
@Injectable()
export class TicketLifecycleService {
private readonly logger = new Logger(TicketLifecycleService.name);
constructor(private readonly repository: TicketRepository) {}
async createTickets(interval: Interval): Promise<void> {
const tours = await this.repository.findTours(interval);
for (const tour of tours) {
if (tour.ticketId) {
continue;
}
const ticketId = await this.repository.searchTicketIDForTour(tour);
if (ticketId) {
await this.repository.addToursToTicket(ticketId, [tour]);
} else {
await this.repository.createTicket([tour]);
}
}
await this.validateCreatedTickets();
// await this.archiveClosedTickets();
}
async validateDocumentInfoUpdate(
ticketId: string,
documentInfo: DocumentInfoArgs,
) {
const ticket = await this.repository.findById(ticketId);
switch (ticket.currentState) {
case TicketValidationState.USER_INTERACTION_REQUIRED:
const approvalStateUpdate = documentInfo.approval.isNeeded
? ApprovalState.REQUESTED
: ApprovalState.FREE;
let stateUpdate: TicketValidationState =
TicketValidationState.USER_INTERACTION_REQUIRED;
if (
documentInfo.tdLocation === 'company' &&
approvalStateUpdate === ApprovalState.FREE
)
stateUpdate = TicketValidationState.ARCHIVED;
else if (documentInfo.tdLocation === 'uncertain')
stateUpdate = TicketValidationState.TD_UNCERTAIN;
else stateUpdate = TicketValidationState.DOCUMENTS_MISSING;
return this.repository.updateTicket(ticketId, {
documentInfo,
approvalState: approvalStateUpdate,
state: stateUpdate,
});
case TicketValidationState.TD_UNCERTAIN:
return this.repository.updateTicket(ticketId, {
documentInfo,
state:
ticket.approvalState === ApprovalState.FREE &&
documentInfo.tdLocation === 'company'
? TicketValidationState.ARCHIVED
: TicketValidationState.DOCUMENTS_MISSING,
});
case TicketValidationState.DOCUMENTS_MISSING:
return this.repository.updateTicket(ticketId, {
documentInfo,
...((ticket.approvalState === ApprovalState.FREE ||
documentInfo.approval.location === 'company') &&
documentInfo.tdLocation === 'company'
? { state: TicketValidationState.ARCHIVED }
: {}),
});
}
}
private async validateCreatedTickets(): Promise<void> {
const tickets = await this.repository.findAllCreated();
this.logger.debug(`Found ${tickets.length} with state CREATED`);
for (const ticket of tickets) {
const {
result: isValid,
errors,
notes,
} = await this.validateCreatedTicket(ticket);
if (isValid) {
const { result: approvalResult } = await this.isApprovalNeeded(ticket);
if (approvalResult) {
await this.repository.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.USER_INTERACTION_REQUIRED,
approvalState: ticket.tours.every(({ patientId }) => !patientId)
? ApprovalState.UNKNOWN
: ApprovalState.REQUIRED,
});
} else {
await this.repository.updateTicket(ticket.id, {
errors,
notes,
state: TicketValidationState.USER_INTERACTION_REQUIRED,
approvalState: ApprovalState.FREE,
});
}
} else {
await this.repository.setActionablesOnTicket(ticket.id, errors, notes);
}
}
}
private async archiveClosedTickets() {
const closedTickets = await this.repository.findAllClosed();
for (const closedTicket of closedTickets) {
await this.repository.setStateOfTicket(
closedTicket.id,
TicketValidationState.ARCHIVED,
);
}
}
private async validateCreatedTicket(
ticket: Ticket & { tours: Tour[] },
): Promise<ValidationResult> {
const patientIds = [
...new Set(ticket.tours.map(({ patientId }) => patientId)),
];
if (patientIds.length > 1) {
return {
result: false,
errors: ['Ticket: Die Touren umfassen mehr als einen Patienten'],
notes: [],
};
}
const [patientId] = patientIds;
if (!patientId && ticket.tours.at(0)?.type !== 'Spontan') {
return {
result: false,
errors: [],
notes: ['Patient: Der Patient ist nicht in DispoLive hinterlegt'],
};
}
let patientResult: ValidationResult;
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
patientResult = validatePatientDataOnTours(ticket.tours);
} else {
try {
const patient = await this.repository.findPatientById(patientId);
patientResult = validatePatient(patient);
} catch {
patientResult = validatePatientDataOnTours(ticket.tours);
}
}
const tourResults = ticket.tours.map((tour) => validateTour(tour));
return [patientResult, ...tourResults].reduce(
(result, results) => ({
result: result.result && results.result,
errors: [...result.errors, ...results.errors],
notes: [...result.notes, ...results.notes],
}),
{ result: true, errors: [], notes: [] } as ValidationResult,
);
}
private async isApprovalNeeded(
ticket: Ticket & { tours: Tour[] },
): Promise<ValidationResult> {
const approvalRequirements = await Promise.all(
ticket.tours.map((tour) => this.checkTourApprovalRequirement(tour)),
);
const isApprovalRequired = approvalRequirements.reduce(
(result, requirement) => result || requirement,
false,
);
return {
result: isApprovalRequired,
errors: [],
notes:
isApprovalRequired && ticket.tours.every(({ patientId }) => !patientId)
? [
'Patient nicht in Stammdaten: Prüfen Sie ob eine Genehmigung benötigt wird',
]
: [],
};
}
private async checkTourApprovalRequirement(tour: Tour): Promise<boolean> {
if (
[
'Einweisung',
'Entlassung',
'Entlassung Vorbestellung',
'Konsil KH - Dia',
'Privatfahrt Rechnung',
'Tagesklinik',
'Verlegung',
].includes(tour.category) ||
tour.healthInsurance === 'KOSTENTRÄGER PATIENT'
) {
return false;
}
if (
tour.patientId &&
['BTW', 'TSW'].includes(tour.ordinanceType) &&
tour.category !== 'Dialyse'
) {
const patient = await this.repository.findPatientById(tour.patientId);
if (
['3', '4', '5'].includes(patient.careDegree) &&
(['aG', 'Bi', 'H'].includes(patient.disabilityMark1) ||
['aG', 'Bi', 'H'].includes(patient.disabilityMark2))
) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,7 @@
export enum TicketSystemEventTypes {
Validated = 'ticket-system.validated',
}
export class TicketSystemValidatedEvent {
constructor(public readonly count: number) {}
}
@@ -0,0 +1,153 @@
import { Patient, Tour } from '@prisma/client';
export interface ValidationResult {
result: boolean;
notes: string[];
errors: string[];
}
export function validatePatient(patient: Patient): ValidationResult {
const notes: string[] = [];
const errors: string[] = [];
let isValid = true;
if (!patient?.name) {
isValid = false;
errors.push('Patient: Vorname ist nicht gefüllt');
}
if (!patient?.surname) {
isValid = false;
errors.push('Patient: Nachname ist nicht gefüllt');
}
if (!patient?.birthday) {
isValid = false;
notes.push('Patient: Geburtstag ist nicht gefüllt');
}
if (!patient?.healthinsurance) {
isValid = false;
errors.push('Patient: Krankenkasse ist nicht gefüllt');
}
if (!patient?.careDegree) {
notes.push('Patient: Pflegegrad ist nicht gefüllt');
}
if (!patient?.disabilityMark1) {
notes.push('Patient: Merkzeichen 1 ist nicht gefüllt');
}
if (!patient?.disabilityMark2) {
notes.push('Patient: Merkzeichen 2 ist nicht gefüllt');
}
return {
result: isValid,
errors,
notes,
};
}
export function validatePatientDataOnTours(tours: Tour[]): ValidationResult {
const errors: string[] = [];
let isValid = true;
for (const tour of tours) {
if (!tour.patientName) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Vorname ist nicht gefüllt`,
);
}
if (!tour.patientSurname) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Nachname ist nicht gefüllt`,
);
}
if (!tour.healthInsurance) {
isValid = false;
errors.push(
`Tour ${tour.operationId}: Patient Krankenversicherung ist nicht gefüllt`,
);
}
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTour(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.ordinanceType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Verordnungsart ist nicht gefüllt`);
}
if (!tour.transportType) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Transportart ist nicht gefüllt`);
}
if (!tour.category) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Kategorie ist nicht gefüllt`);
}
if (!tour.startDate) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Startzeit ist nicht gefüllt`);
}
if (!tour.startStreet || !tour.startZip || !tour.startCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Start Adresse ist nicht gefüllt`);
}
if (!tour.targetStreet || !tour.targetZip || !tour.targetCity) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Ziel Adresse ist nicht gefüllt`);
}
if (tour.occupiedKm > 9990) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Besetzte Kilometer sind zu hoch`);
}
return {
result: isValid,
errors,
notes: [],
};
}
export function validateTransport(tour: Tour): ValidationResult {
const errors: string[] = [];
let isValid = true;
if (!tour.carName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrzeugname ist nicht angegeben`);
}
if (!tour.driverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Fahrer ist nicht angegeben`);
}
if (!tour.codriverName) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Beifahrer ist nicht angegeben`);
}
if (!tour.startBegin) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Anfahrt-Stempel fehlt`);
}
if (!tour.empty) {
isValid = false;
errors.push(`Tour ${tour.operationId}: Frei-Stempel fehlt`);
}
return {
result: isValid,
errors,
notes: [],
};
}
@@ -0,0 +1,87 @@
import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
ApprovalState,
Prisma,
Ticket,
TicketValidationState,
} from '@prisma/client';
import { Paginated } from 'src/core/base/pagination.input';
@ObjectType('ApprovalInfo')
export class ApprovalInfoObjectType {
@Field({ nullable: true })
isNeeded?: boolean;
@Field({ nullable: true })
whoRequested?: string;
@Field({ nullable: true })
answeredFrom?: string;
@Field({ nullable: true })
location?: string;
}
@ObjectType('DocumentInfo')
export class DocumentInfoObjectType {
@Field(() => ApprovalInfoObjectType)
approval: ApprovalInfoObjectType;
@Field({ nullable: true })
tdLocation: string;
}
@ObjectType('Ticket')
export class TicketObjectType implements Ticket {
@Field()
id: string;
@Field(() => TicketValidationState)
currentState: TicketValidationState;
@Field(() => ApprovalState, { nullable: true })
approvalState: ApprovalState | null;
@Field(() => [String])
errors: string[];
@Field(() => [String])
notes: string[];
@Field(() => Boolean)
isUrgent: boolean;
@Field()
urgency: Date;
@Field(() => DocumentInfoObjectType, { nullable: true })
documentInfo: Prisma.JsonValue;
@Field()
createdAt: Date;
}
@ObjectType('PaginatedTicket')
export class PaginatedTicketObjectType extends Paginated(TicketObjectType) {}
@ObjectType('TicketsUpdated')
export class TicketsUpdatedObjectType {
@Field(() => Int)
count: number;
}
@ObjectType('TicketStateMeta')
export class TicketStateMetaObjectType {
@Field(() => Int)
count: number;
@Field(() => Int, { nullable: true })
approvalRequiredCount?: number;
@Field(() => Int, { nullable: true })
approvalUnknownCount?: number;
@Field(() => Int, { nullable: true })
tdNotCompanyCount?: number;
@Field(() => Int, { nullable: true })
approvalNotCompanyCount?: number;
}
@ObjectType('TicketStateKeyFigures')
export class TicketStateKeyFiguresObjectType {
@Field(() => Int)
dwellTime: number;
}
registerEnumType(TicketValidationState, {
name: 'TicketValidationState',
});
registerEnumType(ApprovalState, {
name: 'ApprovalState',
});
@@ -0,0 +1,619 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import {
ApprovalState,
Patient,
Prisma,
TicketValidationState,
Tour,
} from '@prisma/client';
import {
differenceInBusinessDays,
endOfDay,
Interval,
startOfDay,
} from 'date-fns';
import { ExpressionBuilder, Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types';
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
import { createPrismaQueryFromTicketFilters } from './ticket-filter';
function withTours(eb: ExpressionBuilder<DB, 'Ticket'>) {
return jsonArrayFrom(
eb
.selectFrom('Tour')
.selectAll('Tour')
.whereRef('Tour.ticketId', '=', 'Ticket.id')
.orderBy('Tour.operationId'),
).as('tours');
}
@Injectable()
export class TicketRepository {
private logger = new Logger(TicketRepository.name);
constructor(
private readonly prisma: PrismaService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async createTicket(tours: Tour[]) {
const ticket = await this.db
.insertInto('Ticket')
.values({
currentState: TicketValidationState.CREATED,
})
.returningAll()
.executeTakeFirst();
await this.db
.insertInto('TicketStateHistory')
.values({
state: TicketValidationState.CREATED,
ticketId: ticket.id,
})
.executeTakeFirst();
await this.db
.updateTable('Tour')
.set({
ticketId: ticket.id,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
return ticket;
}
async addToursToTicket(ticketId: string, tours: Tour[]) {
await this.db
.updateTable('Tour')
.set({
ticketId: ticketId,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
return this.db
.selectFrom('Ticket')
.where('id', '=', ticketId)
.selectAll()
.executeTakeFirst();
}
setActionablesOnTicket(ticketId: string, errors: string[], notes: string[]) {
return this.db
.updateTable('Ticket')
.set({ errors, notes })
.where('id', '=', ticketId)
.returningAll()
.executeTakeFirst();
}
async setStateOfTicket(ticketId: string, state: TicketValidationState) {
await this.db
.updateTable('Ticket')
.set({
currentState: state,
})
.where('Ticket.id', '=', ticketId)
.execute();
return this.db
.insertInto('TicketStateHistory')
.values({ state, ticketId })
.returningAll()
.executeTakeFirst();
}
async updateTicket(
ticketId: string,
data: {
state?: TicketValidationState;
errors?: string[];
notes?: string[];
isUrgent?: boolean;
urgency?: Date;
approvalState?: ApprovalState;
documentInfo?: DocumentInfoArgs;
approvalPath?: string;
},
) {
const { state, ...ticketData } = data;
await this.db
.updateTable('Ticket')
.set({ ...ticketData })
.where('id', '=', ticketId)
.execute();
if (state) {
await this.setStateOfTicket(ticketId, state);
}
return this.db
.selectFrom('Ticket')
.where('id', '=', ticketId)
.selectAll('Ticket')
.select((eb) => withTours(eb))
.executeTakeFirst();
}
findById(id: string) {
return this.prisma.ticket.findUnique({
where: {
id,
},
include: {
tours: true,
},
});
}
findAll() {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.ARCHIVED,
},
},
},
include: {
tours: true,
},
});
}
findAllCreated() {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.USER_INTERACTION_REQUIRED,
},
},
},
include: {
tours: true,
},
});
}
findAllClosed() {
return this.prisma.ticket.findMany({
where: {
tours: {
every: {
check: {
not: 0,
},
},
},
},
});
}
search(query: string) {
return this.prisma.ticket.findMany({
where: {
state: {
none: {
state: TicketValidationState.ARCHIVED,
},
},
tours: {
some: {
OR: [
...query.split(' ').flatMap((word) => [
{
patientName: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
{
patientSurname: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
{
operationId: {
contains: word,
mode: Prisma.QueryMode.insensitive,
},
},
]),
],
},
},
},
take: 5,
include: {
tours: true,
},
});
}
async findByState(
state: TicketValidationState,
filter: TicketFilterArgs,
take?: number,
skip?: number,
) {
const query: Prisma.TicketFindManyArgs = {
...{
...(skip ? { skip, take: 10 } : {}),
...(take ? { take } : {}),
...(take && !skip ? { skip: 0 } : {}),
},
where: {
AND: createPrismaQueryFromTicketFilters(filter),
currentState: state,
tours: {
some: {
check: 0,
},
},
},
orderBy: { createdAt: 'desc' },
};
const [tickets, count] = await this.prisma.$transaction([
this.prisma.ticket.findMany({ ...query }),
this.prisma.ticket.count({ where: query.where }),
]);
return {
tickets,
total: count,
};
}
async calculateTicketStateMeta(ticketState: TicketValidationState) {
const ticketWithCurrentStateAndTours: Prisma.TicketWhereInput = {
currentState: ticketState,
tours: { some: { check: 0 } },
};
const [
count,
approvalRequiredCount,
approvalUnknownCount,
tdNotCompanyCount,
approvalNotCompanyCount,
] = await this.prisma.$transaction([
this.prisma.ticket.count({
where: { ...ticketWithCurrentStateAndTours },
}),
this.prisma.ticket.aggregate({
_count: {
id: true,
},
where: {
...ticketWithCurrentStateAndTours,
approvalState: ApprovalState.REQUIRED,
},
}),
this.prisma.ticket.aggregate({
_count: {
id: true,
},
where: {
...ticketWithCurrentStateAndTours,
approvalState: ApprovalState.UNKNOWN,
},
}),
this.prisma.ticket.aggregate({
_count: {
id: true,
},
where: {
...ticketWithCurrentStateAndTours,
documentInfo: {
path: ['approval', 'location'],
not: 'company',
},
},
}),
this.prisma.ticket.aggregate({
_count: {
id: true,
},
where: {
...ticketWithCurrentStateAndTours,
documentInfo: {
path: ['tdLocation'],
not: 'company',
},
},
}),
]);
return {
count,
approvalRequiredCount: approvalRequiredCount._count.id,
approvalUnknownCount: approvalUnknownCount._count.id,
tdNotCompanyCount: tdNotCompanyCount._count.id,
approvalNotCompanyCount: approvalNotCompanyCount._count.id,
};
}
async calculateTicketStateKeyFigures(ticketState: TicketValidationState) {
const ticketWithCurrentStateAndTours: Prisma.TicketWhereInput = {
tours: { some: { check: 0 } },
};
const ticketWithState = await this.prisma.ticket.findMany({
where: { ...ticketWithCurrentStateAndTours },
include: {
state: true,
},
});
const dwellTimesForTicketState = ticketWithState
.map(({ state }) => {
console.log(state);
const stateToCalculateIdx = state.findIndex(
({ state }) => state === ticketState,
);
if (stateToCalculateIdx > -1 && state[stateToCalculateIdx + 1]) {
const nextState = state[stateToCalculateIdx + 1];
return differenceInBusinessDays(
new Date(nextState.createdAt),
new Date(state[stateToCalculateIdx].createdAt),
);
} else if (stateToCalculateIdx > -1) {
return differenceInBusinessDays(
new Date(),
new Date(state[stateToCalculateIdx].createdAt),
);
} else {
return undefined;
}
})
.filter(Boolean);
return {
dwellTime:
dwellTimesForTicketState.reduce((a, b) => a + b, 0) /
dwellTimesForTicketState.length,
};
}
findPatientById(patientId: string): Promise<Patient> {
return this.prisma.patient.findUnique({ where: { id: patientId } });
}
findStatesByTicketId(
ticketId: string,
): Promise<{ state: TicketValidationState }[]> {
return this.prisma.ticketStateHistory.findMany({
where: {
ticketId,
},
select: {
state: true,
},
});
}
findToursByTicketId(ticketId: string): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
ticketId,
},
});
}
async searchTicketIDForTour(tour: Tour): Promise<string | undefined> {
const tours = await Promise.all([
this.findFirstRecurringTour(tour),
this.findTourOnSameDayWithOppositeDirection(tour),
]);
const firstFoundTour = tours.filter((tour) => !!tour)[0];
if (firstFoundTour) {
const ticket = await this.prisma.ticket.findFirst({
select: {
id: true,
},
where: {
tours: {
some: {
id: firstFoundTour.id,
},
},
},
});
return ticket?.id;
}
return undefined;
}
findFirstRecurringTour(tour: Tour): Promise<Tour | null> {
const institutionStreet =
tour.direction === 'Hinfahrt' ? tour.targetStreet : tour.startStreet;
return this.prisma.tour.findFirst({
where: {
type: 'Serie',
category: tour.category,
ordinanceType: tour.ordinanceType,
patientId: tour.patientId,
patientName: tour.patientName,
patientSurname: tour.patientSurname,
patientStreet: tour.patientStreet,
patientCity: tour.patientCity,
patientZip: tour.patientZip,
OR: [
{ targetStreet: institutionStreet },
{ startStreet: institutionStreet },
],
rangeEndDate: {
gte: tour.startDate,
},
ticketId: {
not: null,
},
},
orderBy: {
startDate: 'desc',
},
});
}
findTourOnSameDayWithOppositeDirection(tour: Tour): Promise<Tour | null> {
return this.prisma.tour.findFirst({
where: {
AND: [
{
startDate: {
gt: startOfDay(new Date(tour.startDate)),
},
},
{
startDate: {
lt: endOfDay(new Date(tour.startDate)),
},
},
{
startStreet: tour.targetStreet,
},
{
targetStreet: tour.startStreet,
},
{
direction: tour.direction === 'Hinfahrt' ? 'Rückfahrt' : 'Hinfahrt',
},
],
},
});
}
findToursWithTicket(interval: Interval): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
ticketId: {
not: null,
},
},
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
],
},
});
}
removeTourFromTicket(tourId: string): Promise<Tour> {
return this.prisma.tour.update({
where: {
id: tourId,
},
data: {
Ticket: {
disconnect: true,
},
},
});
}
findTours(interval: Interval): Promise<Tour[]> {
return this.prisma.tour.findMany({
where: {
AND: [
{
startDate: {
gte: new Date(interval.start),
},
},
{
startDate: {
lt: new Date(interval.end),
},
},
{
OR: [
{
carName: null,
},
{
AND: [
{
carName: {
not: 'Storno',
},
},
{
carName: {
not: 'Löschen',
},
},
{
carName: {
not: 'Abgabe',
},
},
],
},
],
},
{
check: {
not: 3,
},
},
{
deletedAt: null,
},
{
ticketId: null,
},
{
AND: [
{
patientId: {
// Pause
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
},
},
{
patientId: {
// Feierabend
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
},
},
],
},
],
},
});
}
async deleteEmptyTickets(): Promise<Prisma.BatchPayload> {
return this.prisma.ticket.deleteMany({
where: {
tours: {
none: {},
},
},
});
}
}
@@ -0,0 +1,82 @@
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
Subscription,
} from '@nestjs/graphql';
import { TicketValidationState } from '@prisma/client';
import { TourObjectType } from '../app-accounting/tour.object-type';
import { DocumentInfoArgs, TicketsArgs } from './ticket-arg.types';
import {
PaginatedTicketObjectType,
TicketObjectType,
TicketStateKeyFiguresObjectType,
TicketStateMetaObjectType,
TicketsUpdatedObjectType,
} from './ticket.object-type';
import { TicketService } from './tickets.service';
@Resolver(() => TicketObjectType)
export class TicketsResolver {
constructor(private readonly ticketsService: TicketService) {}
@Query(() => TicketObjectType)
ticket(@Args('id') id: string) {
return this.ticketsService.findById(id);
}
@Query(() => PaginatedTicketObjectType)
tickets(@Args() { state, filters, cursor, take }: TicketsArgs) {
return this.ticketsService.paginateTicketsByState(
state,
filters,
take,
cursor,
);
}
@Query(() => [TicketObjectType])
searchTickets(@Args('query') query: string) {
return this.ticketsService.searchTickets(query);
}
@Query(() => TicketStateMetaObjectType)
ticketStateMeta(
@Args('state', { type: () => TicketValidationState })
ticketState: TicketValidationState,
) {
return this.ticketsService.getTicketStateMeta(ticketState);
}
@Query(() => TicketStateKeyFiguresObjectType)
ticketStateKeyFigures(
@Args('state', { type: () => TicketValidationState })
ticketState: TicketValidationState,
) {
return this.ticketsService.getTicketStateKeyFigures(ticketState);
}
@Mutation(() => TicketObjectType)
setDocumentInfo(
@Args('ticketId', { type: () => String }) ticketId: string,
@Args() { approval, tdLocation }: DocumentInfoArgs,
) {
return this.ticketsService.updateDocumentInfo(ticketId, {
approval,
tdLocation,
});
}
@ResolveField(() => [TourObjectType])
tours(@Parent() ticket: TicketObjectType) {
return this.ticketsService.findToursByTicketId(ticket.id);
}
@Subscription(() => TicketsUpdatedObjectType)
ticketsUpdated() {
return this.ticketsService.pubSub.asyncIterator('ticketsUpdated');
}
}
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Ticket, TicketValidationState, Tour } from '@prisma/client';
import { Interval } from 'date-fns';
import { PubSub } from 'graphql-subscriptions';
import {
decodeOffsetCursor,
encodeCursor,
} from 'src/core/base/pagination.util';
import { DocumentInfoArgs, TicketFilterArgs } from './ticket-arg.types';
import { TicketLifecycleService } from './ticket-lifecycle.service';
import {
TicketSystemEventTypes,
TicketSystemValidatedEvent,
} from './ticket-system.events';
import {
PaginatedTicketObjectType,
TicketStateKeyFiguresObjectType,
TicketStateMetaObjectType,
} from './ticket.object-type';
import { TicketRepository } from './ticket.repository';
@Injectable()
export class TicketService {
public readonly pubSub = new PubSub();
@OnEvent(TicketSystemEventTypes.Validated, { async: true })
notifyOnTicketsValidated(event: TicketSystemValidatedEvent): void {
this.pubSub.publish('ticketsUpdated', {
ticketsUpdated: { count: event.count },
});
}
constructor(
private readonly repository: TicketRepository,
private readonly lifecycle: TicketLifecycleService,
private readonly eventEmitter: EventEmitter2,
) {}
async createTickets(interval: Interval): Promise<void> {
await this.lifecycle.createTickets(interval);
this.eventEmitter.emit(
TicketSystemEventTypes.Validated,
new TicketSystemValidatedEvent(0),
);
}
async updateDocumentInfo(ticketId: string, documentInfo: DocumentInfoArgs) {
return this.lifecycle.validateDocumentInfoUpdate(ticketId, documentInfo);
}
findById(id: string): Promise<Ticket> {
return this.repository.findById(id);
}
findAll(): Promise<Ticket[]> {
return this.repository.findAll() as Promise<Ticket[]>;
}
searchTickets(query: string): Promise<Ticket[]> {
return this.repository.search(query);
}
async paginateTicketsByState(
state: TicketValidationState,
filter: TicketFilterArgs,
take?: number,
cursor?: string,
): Promise<PaginatedTicketObjectType> {
const skip = cursor ? decodeOffsetCursor(cursor) : 0;
const { tickets, total } = await this.repository.findByState(
state,
filter,
take,
skip,
);
const prevPage = skip - take < 0 ? 0 : skip - take;
const nextPage = skip + take < total ? skip + take : skip;
return {
nodes: tickets,
prevCursor: encodeCursor(prevPage),
endCursor: encodeCursor(nextPage),
hasNextPage: nextPage !== skip,
totalCount: total,
};
}
async getTicketStateMeta(
ticketState: TicketValidationState,
): Promise<TicketStateMetaObjectType> {
return this.repository.calculateTicketStateMeta(ticketState);
}
getTicketStateKeyFigures(
ticketState: TicketValidationState,
): Promise<TicketStateKeyFiguresObjectType> {
return this.repository.calculateTicketStateKeyFigures(ticketState);
}
findToursByTicketId(ticketId: string): Promise<Tour[]> {
return this.repository.findToursByTicketId(ticketId);
}
findStatesByTicketId(ticketId: string): Promise<TicketValidationState[]> {
return this.repository
.findStatesByTicketId(ticketId)
.then((states) => states.map(({ state }) => state));
}
}
@@ -15,25 +15,28 @@ import { AnomalyTypes } from './anomaly-detectors/anomaly-types.enum';
import { DriverCertificateDetector } from './anomaly-detectors/driver-certificate.detector';
import { DriverCodriverSameDetector } from './anomaly-detectors/driver-codriver-same.detector';
import { EmployeesLoggedInDetector } from './anomaly-detectors/employees-logged-in.detector';
import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
// import { OneTourAtATimeDetector } from './anomaly-detectors/one-tour-at-a-time.detector';
import { WorkingEmployeeNotInDistributionSystemDetector } from './anomaly-detectors/working-employee-not-in-distribution-system.detector';
import { AnomalyDetector } from './anomaly-detectors/anomaly-detector.class';
@Injectable()
export class AnomaliesService {
private detectors = [
new EmployeesLoggedInDetector(this.anomalyDetectorService),
new DriverCertificateDetector(this.anomalyDetectorService),
new DriverCodriverSameDetector(this.anomalyDetectorService),
new OneTourAtATimeDetector(this.anomalyDetectorService),
new WorkingEmployeeNotInDistributionSystemDetector(
this.anomalyDetectorService,
),
];
private detectors: AnomalyDetector[];
constructor(
private readonly prisma: PrismaService,
private readonly anomalyDetectorService: AnomalyDetectorService,
) {}
) {
this.detectors = [
new EmployeesLoggedInDetector(this.anomalyDetectorService),
new DriverCertificateDetector(this.anomalyDetectorService),
new DriverCodriverSameDetector(this.anomalyDetectorService),
// new OneTourAtATimeDetector(this.anomalyDetectorService),
new WorkingEmployeeNotInDistributionSystemDetector(
this.anomalyDetectorService,
),
];
}
detectAnomalies(interval: Interval): Promise<Anomaly[]> {
return Promise.all(
@@ -105,6 +105,9 @@ export class AnomalyDetectorService {
},
],
},
orderBy: {
startDate: 'asc',
},
});
}
@@ -267,19 +267,6 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
const datetimeToCheckWithBuffer = addMinutes(datetimeToCheck, 5);
if (personnelNumber === '23') {
console.log(
datetimeToCheck,
{
start: new Date(workTime.startDate),
end: new Date(workTime.endDate),
},
{
start: new Date(firstClockedIn.dateTime),
end: new Date(lastClockedOut.dateTime),
},
);
}
try {
if (workTime) {
const interval = {
@@ -1,5 +1,5 @@
import { Logger } from '@nestjs/common';
import { Anomaly, Prisma } from '@prisma/client';
import { Anomaly, Prisma, Tour } from '@prisma/client';
import {
addDays,
areIntervalsOverlapping,
@@ -7,11 +7,11 @@ import {
format,
Interval,
isBefore,
isWithinInterval,
isSameDay,
set,
startOfDay,
} from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
import { fromZonedTime, toZonedTime } from 'date-fns-tz';
import { AnomalyDetector } from './anomaly-detector.class';
import { AnomalyTypes } from './anomaly-types.enum';
@@ -20,177 +20,209 @@ export class OneTourAtATimeDetector extends AnomalyDetector {
private readonly logger = new Logger(OneTourAtATimeDetector.name);
readonly type = AnomalyTypes.OneTourAtATime;
private groupToursByDay(tours: Tour[]): Record<string, Tour[]> {
return tours.reduce(
(acc, cur) => {
const dayString = new Date(cur.startDate).toDateString();
const group = acc[dayString] ?? [];
group.push(cur);
return { ...acc, [dayString]: group };
},
{} as Record<string, Tour[]>,
);
}
async detect(inInterval: Interval): Promise<Prisma.AnomalyCreateInput[]> {
const toursInRange = await this.dataService.findToursInInterval(inInterval);
return toursInRange
.map(
(
{
operationId,
startDate,
startBegin,
startEnd,
targetBegin,
target,
empty,
carName,
driverId,
driverName,
codriverId,
codriverName,
},
_,
tours,
) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [start] = [startBegin, startEnd].sort();
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
const [end] = [targetBegin, target, empty]
.filter((val) => !!val)
.sort()
.reverse();
const toursMap = this.groupToursByDay(toursInRange);
const result = [];
for (const [, tours] of Object.entries(toursMap)) {
const intermediateStep = tours
.map(
(
{
operationId,
startDate,
startBegin,
startEnd,
targetBegin,
target,
empty,
carName,
driverId,
driverName,
codriverId,
codriverName,
},
_,
tours,
) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [start] = [startBegin, startEnd].sort();
// somehow empty can be before everything else, so we use targetBegin, target and empty and take the higher value ...
const [end] = [targetBegin, target, empty]
.filter((val) => !!val)
.sort()
.reverse();
if (!start || !end) {
if (isBefore(new Date(), startDate)) {
this.logger.warn(
`Tour ${operationId} started in the past, but is missing start or end time`,
);
}
return [];
}
const tourInterval = this.getTourInterval(start, end, startDate);
const toursInInterval = tours
.filter((tour) =>
isWithinInterval(tour.startDate, {
start: startOfDay(startDate),
end: endOfDay(startDate),
}),
)
.filter(
({
operationId: tourListOperationId,
carName: tourListCarName,
}) =>
tourListOperationId !== operationId &&
carName !== tourListCarName,
)
.filter(
({
startDate: tourListStartDate,
startBegin: tourListStartBegin,
startEnd: tourListStartEnd,
targetBegin: tourListTargetBegin,
target: tourListTarget,
empty: tourListEmpty,
}) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [tourListStart] = [
tourListStartBegin,
tourListStartEnd,
].sort();
// somehow empty can be before everything else, so we use targetBegin and empty and take the higher value ...
const [tourListEnd] = [
tourListTargetBegin,
tourListTarget,
tourListEmpty,
]
.filter((val) => !!val)
.sort()
.reverse();
if (!tourListStart || !tourListEnd) {
return false;
}
const tourListInterval = this.getTourInterval(
tourListStart,
tourListEnd,
tourListStartDate,
if (!start || !end) {
if (isBefore(new Date(), startDate)) {
this.logger.warn(
`Tour ${operationId} started in the past, but is missing start or end time`,
);
try {
return areIntervalsOverlapping(
tourInterval,
tourListInterval,
}
return [];
}
const tourInterval = this.getTourInterval(start, end, startDate);
const toursInInterval = tours
.filter(
(tour) => isSameDay(tour.startDate, startDate),
// isWithinInterval(tour.startDate, {
// start: startOfDay(startDate),
// end: endOfDay(startDate),
// }),
)
.filter(
({
operationId: tourListOperationId,
carName: tourListCarName,
}) =>
tourListOperationId !== operationId &&
carName !== tourListCarName,
)
.filter(
({
startDate: tourListStartDate,
startBegin: tourListStartBegin,
startEnd: tourListStartEnd,
targetBegin: tourListTargetBegin,
target: tourListTarget,
empty: tourListEmpty,
}) => {
// somehow startBegin can be after empty, so we use startBegin and startEnd and take the lower value and hope for the best
const [tourListStart] = [
tourListStartBegin,
tourListStartEnd,
].sort();
// somehow empty can be before everything else, so we use targetBegin and empty and take the higher value ...
const [tourListEnd] = [
tourListTargetBegin,
tourListTarget,
tourListEmpty,
]
.filter((val) => !!val)
.sort()
.reverse();
if (!tourListStart || !tourListEnd) {
return false;
}
const tourListInterval = this.getTourInterval(
tourListStart,
tourListEnd,
tourListStartDate,
);
} catch (error) {
this.logger.error(
'tourListInterval',
error,
tourListInterval,
tourInterval,
);
return false;
}
},
try {
return areIntervalsOverlapping(
tourInterval,
tourListInterval,
);
} catch (error) {
this.logger.error(
'tourListInterval',
error,
tourListInterval,
tourInterval,
);
return false;
}
},
);
const toursInIntervalWithDriver = toursInInterval.filter(
({
driverId: tourListDriverId,
codriverId: tourListCodriverId,
}) =>
driverId === tourListDriverId ||
driverId === tourListCodriverId,
);
const toursInIntervalWithCodriver = toursInInterval.filter(
({
driverId: tourListDriverId,
codriverId: tourListCodriverId,
}) =>
codriverId === tourListDriverId ||
codriverId === tourListCodriverId,
);
const toursInIntervalWithDriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
driverId === tourListDriverId || driverId === tourListCodriverId,
);
const toursInIntervalWithCodriver = toursInInterval.filter(
({ driverId: tourListDriverId, codriverId: tourListCodriverId }) =>
codriverId === tourListDriverId ||
codriverId === tourListCodriverId,
);
const overlappingCarsWithDriver = toursInIntervalWithDriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithDriver = toursInIntervalWithDriver.map(
({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}),
);
const overlappingCarsWithCodriver = toursInIntervalWithCodriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithCodriver =
toursInIntervalWithCodriver.map(({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}));
return [
toursInIntervalWithDriver.length
? this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
driverName,
startDate,
[carName, ...overlappingCarsWithDriver],
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
},
...overlappingTourInfoWithDriver,
],
)
: undefined,
toursInIntervalWithCodriver.length
? this.createAnomaly(
`${operationId}-${codriverId}-${this.type}`,
operationId,
codriverName,
startDate,
[carName, ...overlappingCarsWithCodriver],
[
{
operationId,
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
},
...overlappingTourInfoWithCodriver,
],
)
: undefined,
];
},
)
.flat()
.filter((anomaly) => !!anomaly);
const overlappingCarsWithDriver = toursInIntervalWithDriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithDriver = toursInIntervalWithDriver.map(
({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}),
);
const overlappingCarsWithCodriver = toursInIntervalWithCodriver.map(
({ carName }) => carName,
);
const overlappingTourInfoWithCodriver =
toursInIntervalWithCodriver.map(({ operationId, startDate }) => ({
startDate: format(new Date(startDate), 'HH:mm dd.MM.yy'),
operationId,
}));
return [
toursInIntervalWithDriver.length
? this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
driverName,
startDate,
[carName, ...overlappingCarsWithDriver],
[
{
operationId,
startDate: format(
new Date(startDate),
'HH:mm dd.MM.yy',
),
},
...overlappingTourInfoWithDriver,
],
)
: undefined,
toursInIntervalWithCodriver.length
? this.createAnomaly(
`${operationId}-${codriverId}-${this.type}`,
operationId,
codriverName,
startDate,
[carName, ...overlappingCarsWithCodriver],
[
{
operationId,
startDate: format(
new Date(startDate),
'HH:mm dd.MM.yy',
),
},
...overlappingTourInfoWithCodriver,
],
)
: undefined,
];
},
)
.flat()
.filter((anomaly) => !!anomaly);
result.push(intermediateStep);
}
return result.flat();
}
async validate(anomaly: Anomaly): Promise<Anomaly> {
@@ -179,8 +179,7 @@ export class CalculationService {
): number {
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
// if TariffGroup is VDEK KTW, sum up costTypes
const isVdek =
tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 ?? false;
const isVdek = tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1;
return tariff.costTypes.reduce((sum, costType) => {
switch (costType.variant) {
@@ -281,12 +280,19 @@ export class CalculationService {
private calculateTSW(tour: Tour, costType: CostType): number {
if (
costType.description.includes('TSW 1') &&
costType.description.includes('TS+R') &&
tour.transportType === 'TS+R'
) {
return costType.costAmount;
} else if (
(costType.description.includes('TS 1') ||
costType.description.includes('TSW 1')) &&
tour.driverId === tour.codriverId
) {
return costType.costAmount;
} else if (
costType.description.includes('TSW 2') &&
(costType.description.includes('TS 2') ||
costType.description.includes('TSW 2')) &&
tour.driverId !== tour.codriverId
) {
return costType.costAmount;
@@ -389,9 +395,16 @@ export class CalculationService {
}
const tenant = await this.prisma.avicennaTenant.findFirst({
where: {
name: {
contains: healthInsurance,
},
OR: [
{
name: healthInsurance,
},
{
name: {
contains: healthInsurance,
},
},
],
tariffGroups: {
some: {
ordinanceType,
@@ -9,4 +9,4 @@ import { PersonnelCostCalculationService } from './personnel-cost-calculation.se
providers: [CalculationService, PersonnelCostCalculationService],
exports: [CalculationService],
})
export class UtilCalculationModule {}
export class FeatureCalculationModule {}
@@ -19,4 +19,21 @@ export const INFECTION_TYPES = {
Norovirus: '2.B',
Sonstiges: '2.A',
VRE: '2.A',
// TODO: Veränderte Infektionstypen ergänzen
// Diphtherie: '',
// Harnwegsinfektion: '',
// 'Hepatitis A,E': '',
// 'Hepatitis B,C,D': '',
// Influenza: '',
// 'Krätze 3.1': '',
// Laus: '',
// MRGN: '',
// 'MRGN (MNR)': '',
// 'MRSA (MNR)': '',
// 'Röteln (Rubeolae)': '',
// Scharlach: '',
// 'TBC Lunge': '',
// 'TBC Organ': '',
// 'VRE (MNR)': '',
};
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { FeatureImporterModule } from './feat-importers/feature-importer.module';
@Module({
imports: [FeatureImporterModule],
exports: [FeatureImporterModule],
})
export class FeatureEtlModule {}
@@ -320,7 +320,11 @@ export class DispoLiveExtractorsService {
surname,
persId,
importPersId,
qualifikation,
qualifikation:
!!qualifikation && typeof qualifikation === 'object'
? //@ts-expect-error ts is wrong here
qualifikation?._id
: qualifikation ?? undefined,
};
}),
),
@@ -1,4 +1,4 @@
export enum EventTypes {
export enum ETLEventTypes {
ExtractionDone = 'etl.extraction',
TransformationDone = 'etl.transformation',
LoadingDone = 'etl.loading',
@@ -7,7 +7,7 @@ import { Observable, combineLatest, from, map, switchMap, tap } from 'rxjs';
import { ExtractorsService } from '../feat-extractors/extractors.service';
import { LoadersService } from '../feat-loaders/loaders.service';
import { TransformersService } from '../feat-transformers/transformers.service';
import { EventTypes } from './events/event-types.enum';
import { ETLEventTypes } from './events/event-types.enum';
import { ExtractionDoneEvent } from './events/extraction-done.event';
import { LoadingDoneEvent } from './events/loading-done.event';
import { TransformationDoneEvent } from './events/transformation-done.event';
@@ -62,7 +62,7 @@ export class ImporterService {
]).pipe(
tap(() =>
this.eventEmitter.emit(
EventTypes.ExtractionDone,
ETLEventTypes.ExtractionDone,
new ExtractionDoneEvent(jobId),
),
),
@@ -84,7 +84,7 @@ export class ImporterService {
]).pipe(
tap(() =>
this.eventEmitter.emit(
EventTypes.TransformationDone,
ETLEventTypes.TransformationDone,
new TransformationDoneEvent(jobId),
),
),
@@ -104,7 +104,7 @@ export class ImporterService {
switchMap(() => this.loadersService.loadWorkTimes()),
tap(() =>
this.eventEmitter.emit(
EventTypes.LoadingDone,
ETLEventTypes.LoadingDone,
new LoadingDoneEvent(jobId),
),
),
@@ -12,6 +12,7 @@ import {
import { Interval } from 'date-fns';
import { PrismaService } from 'src/core/database/prisma.service';
import { chunk } from 'src/utils';
@Injectable()
export class FinalRepository {
@@ -50,16 +51,21 @@ export class FinalRepository {
createTours(
tourCreateInput: Prisma.TourCreateInput[],
): Promise<Prisma.BatchPayload> {
return this.prisma.tour.createMany({
data: tourCreateInput,
skipDuplicates: true,
});
): Promise<Prisma.BatchPayload[]> {
const chunks = chunk(tourCreateInput, 25000);
return Promise.all(
chunks.map((chunk) =>
this.prisma.tour.createMany({
data: chunk,
skipDuplicates: true,
}),
),
);
}
async updateTours(
tourCreateInput: Prisma.TourCreateInput[],
): Promise<Prisma.BatchPayload> {
): Promise<Prisma.BatchPayload[]> {
await this.prisma.tour.deleteMany({
where: {
operationId: {
@@ -70,17 +76,22 @@ export class FinalRepository {
return this.createTours(tourCreateInput);
}
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload> {
return this.prisma.tour.updateMany({
where: {
operationId: {
in: operationIds,
},
},
data: {
deletedAt: new Date(),
},
});
deleteTours(operationIds: string[]): Promise<Prisma.BatchPayload[]> {
const chunks = chunk(operationIds, 20000);
return Promise.all(
chunks.map((chunk) =>
this.prisma.tour.updateMany({
where: {
operationId: {
in: chunk,
},
},
data: {
deletedAt: new Date(),
},
}),
),
);
}
findPatients(): Promise<Patient[]> {
@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/core/database/prisma.module';
import { UtilCalculationModule } from '../feat-calculation/util-calculation.module';
import { FeatureCalculationModule } from 'src/modules/feat-calculation/feat-calculation.module';
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
import { ToursCalculationAdapater } from './adapter/tours-calculation.adapter';
import { StagingRepository } from './staging.repository';
import { TransformersService } from './transformers.service';
@Module({
imports: [PrismaModule, DataAccessExtractorsModule, UtilCalculationModule],
imports: [PrismaModule, DataAccessExtractorsModule, FeatureCalculationModule],
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
exports: [TransformersService],
})
@@ -2,7 +2,7 @@ import { parse, parseISO } from 'date-fns';
import { fromZonedTime } from 'date-fns-tz';
export const parseDateTime = (format: string) => (value: string) =>
parse(value, format, new Date());
!!value ? parse(value, format, new Date()) : value;
export const parseIsoDateTime = (value: string) => parseISO(value);
export const parseZonedDateTime =
@@ -1,4 +1,5 @@
import { PatientSource } from '@prisma/client';
import { fakerDE as faker } from '@faker-js/faker';
import { isValidDate } from 'src/utils';
import { PatientPreStaging } from '../staging.interface';
import {
@@ -11,6 +12,9 @@ export class PatientTransformer {
public static transform(patientSource: PatientSource): PatientPreStaging {
const patientParsed = {
...patientSource,
surname: faker.person.lastName(),
name: faker.person.firstName(),
street: faker.location.streetAddress(),
...transformProperties(parseDateTime('yyyy-MM-dd'))(
pick(['birthday'])(patientSource),
),
@@ -1,4 +1,5 @@
import { PlannedTimeSource } from '@prisma/client';
import { fakerDE as faker } from '@faker-js/faker';
import { PlannedTimePreStaging } from '../staging.interface';
import {
parseBoolean,
@@ -14,6 +15,8 @@ export class PlannedTimeTransformer {
): PlannedTimePreStaging {
const plannedTimeParsed = {
...plannedTimeSource,
firstname: faker.person.firstName(),
surname: faker.person.lastName(),
...transformProperties(parseInteger)(
pick(['id', 'user_id'])(plannedTimeSource),
),
@@ -1,4 +1,5 @@
import { TourSource } from '@prisma/client';
import { fakerDE as faker } from '@faker-js/faker';
import { TourPreStaging } from '../staging.interface';
import {
parseBoolean,
@@ -15,6 +16,16 @@ export class TourTransformer {
public static transform(tourSource: TourSource): TourPreStaging {
const tourParsed = {
...tourSource,
operationId: faker.number.int({ min: 10000, max: 19999 }),
driverName: faker.person.fullName(),
codriverName: faker.person.fullName(),
patientName: faker.person.firstName(),
patientSurname: faker.person.lastName(),
patientStreet: faker.location.streetAddress(),
startInstitution: faker.company.name(),
startStreet: faker.location.streetAddress(),
targetInstitution: faker.company.name(),
targetStreet: faker.location.streetAddress(),
...transformProperties(parseInteger)(pick(['check'])(tourSource)),
...transformProperties(parseFloat)(
pick(['besetztKm', 'gesamtPreis'])(tourSource),
@@ -1,4 +1,5 @@
import { WorkTimeSource } from '@prisma/client';
import { fakerDE as faker } from '@faker-js/faker';
import { WorkTimePreStaging } from '../staging.interface';
import {
parseCETDateTime,
@@ -11,6 +12,8 @@ export class WorkTimeTransformer {
public static transform(workTimeSource: WorkTimeSource): WorkTimePreStaging {
const workTimeParsed = {
...workTimeSource,
firstname: faker.person.firstName(),
surname: faker.person.lastName(),
...transformProperties(parseInteger)(
pick(['id', 'user_id'])(workTimeSource),
),
@@ -22,22 +22,27 @@ export class TicketLifecycleService {
constructor(private readonly repository: TicketRepository) {}
async createTickets(interval: Interval): Promise<void> {
const tours = await this.repository.findTours(interval);
try {
const tours = await this.repository.findTours(interval);
for (const tour of tours) {
if (tour.ticketId) {
continue;
}
const ticketId = await this.repository.searchTicketIDForTour(tour);
if (ticketId) {
await this.repository.addToursToTicket(ticketId, [tour]);
} else {
await this.repository.createTicket([tour]);
for (const tour of tours) {
if (tour.ticketId) {
continue;
}
const ticketId = await this.repository.searchTicketIDForTour(tour);
if (ticketId) {
await this.repository.addToursToTicket(ticketId, [tour]);
} else {
await this.repository.createTicket([tour]);
}
}
await this.validateCreatedTickets();
// await this.archiveClosedTickets();
} catch (error) {
this.logger.error(error);
}
await this.validateCreatedTickets();
// await this.archiveClosedTickets();
}
async validateDocumentInfoUpdate(
@@ -162,11 +167,11 @@ export class TicketLifecycleService {
if (!patientId && ticket.tours.at(0)?.type === 'Spontan') {
patientResult = validatePatientDataOnTours(ticket.tours);
} else {
try {
const patient = await this.repository.findPatientById(patientId);
patientResult = validatePatient(patient);
} catch {
const patient = await this.repository.findPatientById(patientId);
if (!patient) {
patientResult = validatePatientDataOnTours(ticket.tours);
} else {
patientResult = validatePatient(patient);
}
}
const tourResults = ticket.tours.map((tour) => validateTour(tour));
@@ -30,35 +30,40 @@ export class TicketRepository {
) {}
async createTicket(tours: Tour[]) {
const ticket = await this.db
.insertInto('Ticket')
.values({
currentState: TicketValidationState.CREATED,
})
.returningAll()
.executeTakeFirst();
this.logger.log(`Creating Ticket for ${tours.length} tours`);
try {
const ticket = await this.db
.insertInto('Ticket')
.values({
currentState: TicketValidationState.CREATED,
})
.returningAll()
.executeTakeFirst();
await this.db
.insertInto('TicketStateHistory')
.values({
state: TicketValidationState.CREATED,
ticketId: ticket.id,
})
.executeTakeFirst();
await this.db
.insertInto('TicketStateHistory')
.values({
state: TicketValidationState.CREATED,
ticketId: ticket.id,
})
.executeTakeFirst();
await this.db
.updateTable('Tour')
.set({
ticketId: ticket.id,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
await this.db
.updateTable('Tour')
.set({
ticketId: ticket.id,
})
.where(
'id',
'in',
tours.map(({ id }) => id),
)
.execute();
return ticket;
return ticket;
} catch (error) {
throw error;
}
}
async addToursToTicket(ticketId: string, tours: Tour[]) {