(dc): data-connector enhancements
This commit is contained in:
@@ -13,18 +13,18 @@ import { DatabaseModule } from './core/database/database.module';
|
||||
import { PrismaModule } from './core/database/prisma.module';
|
||||
import { ApplicationAccountingModule } from './modules/app-accounting/app-accounting.module';
|
||||
import { ApplicationControlCenterModule } from './modules/app-control-center/app-control-center.module';
|
||||
import { ApplicationManagerModule } from './modules/app-manager/app-manager.module';
|
||||
import { ManagerModule } from './modules/app-manager/app-manager.module';
|
||||
import { ApplicationTicketSystemModule } from './modules/app-ticket-system/app-ticket-system.module';
|
||||
import { AnomalyDetectionModule } from './modules/feat-anomalies/anomaly-detection.module';
|
||||
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
|
||||
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
|
||||
import { TicketSystemModule } from './modules/feat-tickets/ticket-system.module';
|
||||
import { FeatureCalculationModule } from './modules/feat-calculation/feat-calculation.module';
|
||||
import { FeatCalculationModule } from './modules/feat-calculation/feat-calculation.module';
|
||||
import { AppHomeModule } from './modules/app-home/app-home.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
|
||||
// DatabaseModule.forRootAsync({
|
||||
// isGlobal: true,
|
||||
// inject: [ConfigService],
|
||||
@@ -81,13 +81,14 @@ import { FeatureCalculationModule } from './modules/feat-calculation/feat-calcul
|
||||
}),
|
||||
FeatureEtlModule,
|
||||
FeatReportsModule,
|
||||
FeatureCalculationModule,
|
||||
FeatCalculationModule,
|
||||
TicketSystemModule,
|
||||
AnomalyDetectionModule,
|
||||
ApplicationManagerModule,
|
||||
ManagerModule,
|
||||
ApplicationAccountingModule,
|
||||
ApplicationControlCenterModule,
|
||||
ApplicationTicketSystemModule,
|
||||
AppHomeModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
|
||||
import {
|
||||
Interval as TimeInterval,
|
||||
addDays,
|
||||
@@ -13,12 +13,20 @@ import { concatMap, from, lastValueFrom, switchMap, take, tap } from 'rxjs';
|
||||
import { ImporterService } from './modules/feat-etl/feat-importers/importer.service';
|
||||
import { TicketService } from './modules/feat-tickets/tickets.service';
|
||||
import { chunk } from './utils';
|
||||
import { AnomaliesService } from './modules/feat-anomalies/anomalies.service';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
private readonly logger = new Logger(AppService.name);
|
||||
|
||||
// @Cron('0 */5 2-20 * * *')
|
||||
constructor(
|
||||
private readonly importer: ImporterService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly anomalieService: AnomaliesService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
@Cron('0 */5 2-20 * * *')
|
||||
async fetchDataContinously() {
|
||||
const now = new Date();
|
||||
const fetchInterval = {
|
||||
@@ -35,6 +43,13 @@ export class AppService {
|
||||
),
|
||||
),
|
||||
switchMap(() => from(this.ticketService.createTickets(fetchInterval))),
|
||||
switchMap(() =>
|
||||
from(
|
||||
this.anomalieService
|
||||
.detectAnomalies(fetchInterval)
|
||||
.then(() => this.anomalieService.validateAnomalies()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
@@ -142,12 +157,6 @@ export class AppService {
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly importer: ImporterService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
) {}
|
||||
|
||||
private splitIntervalIntoPeriods(
|
||||
interval: TimeInterval,
|
||||
chunkSize = 30,
|
||||
|
||||
@@ -66,6 +66,18 @@ type DocumentInfo {
|
||||
tdLocation: String
|
||||
}
|
||||
|
||||
type HomeDashboardKPIs {
|
||||
anomalyCount: Int!
|
||||
billableTourCount: Int!
|
||||
billableTourRevenueSum: Float!
|
||||
billedTourCount: Int!
|
||||
billedTourNegativeRevenueDeviation: Float!
|
||||
billedTourPositiveRevenueDeviation: Float!
|
||||
employeesWithoutTourCount: Int!
|
||||
revenueTrend: [RevenueTrend!]!
|
||||
toursWithoutApprovalCount: Int!
|
||||
}
|
||||
|
||||
type Interval {
|
||||
end: DateTime!
|
||||
start: DateTime!
|
||||
@@ -88,6 +100,17 @@ type KPIMetadata {
|
||||
tourAmount: Float!
|
||||
}
|
||||
|
||||
type KpiInfo {
|
||||
additional: [Metric!]!
|
||||
main: Metric!
|
||||
secondary: Metric!
|
||||
}
|
||||
|
||||
type Metric {
|
||||
label: String!
|
||||
value: Float!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
acceptAnomaly(id: String!): Anomaly!
|
||||
setAnomalyToSleep(id: String!): Anomaly!
|
||||
@@ -133,14 +156,31 @@ type Query {
|
||||
anomaliesProto: [Anomaly!]!
|
||||
anomaly(id: String!): Anomaly!
|
||||
groupedAnomalies(cursor: String, take: Int! = 10): PaginatedAnomalyGroup!
|
||||
homeDashboardKpis: HomeDashboardKPIs!
|
||||
managerKpi(ordinanceType: String!): KpiInfo!
|
||||
searchTickets(query: String!): [Ticket!]!
|
||||
ticket(id: String!): Ticket!
|
||||
ticketStateKeyFigures(state: TicketValidationState!): TicketStateKeyFigures!
|
||||
ticketStateKpi(state: TicketValidationState!): KpiInfo!
|
||||
ticketStateMeta(state: TicketValidationState!): TicketStateMeta!
|
||||
tickets(cursor: String, filters: TicketFilterArgs, state: TicketValidationState!, take: Int! = 10): PaginatedTicket!
|
||||
timePerTourMetricForDay(day: DateTime!, ordinanceType: String!): [TimePerTourMetric!]!
|
||||
timePerTourMetricForMonth(day: DateTime!, ordinanceType: String!): [TimePerTourMetric!]!
|
||||
tourCostBreakdown(operationId: String!): TourCostBreakdown!
|
||||
toursPerTimeKPI(ordinanceTypes: String!): [TimePerTourKPISummary!]!
|
||||
upcomingTours: [Tour!]!
|
||||
}
|
||||
|
||||
type RevenueTrend {
|
||||
date: DateTime!
|
||||
ordinanceType: String!
|
||||
revenueSum: Float!
|
||||
}
|
||||
|
||||
type ServicePosition {
|
||||
description: String!
|
||||
value: Float!
|
||||
variant: String!
|
||||
}
|
||||
|
||||
type SolutionTarget {
|
||||
@@ -287,7 +327,15 @@ type Tour {
|
||||
type: String!
|
||||
}
|
||||
|
||||
type TourCostBreakdown {
|
||||
servicePositions: [ServicePosition!]!
|
||||
tariffDescription: String!
|
||||
tariffValidityPeriod: String!
|
||||
}
|
||||
|
||||
type TourStateMeta {
|
||||
count: Int!
|
||||
negativeRevenueDeviation: Float
|
||||
positiveRevenueDeviation: Float
|
||||
revenueSum: Float
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// const tracer = require('./tracer');
|
||||
import tracer from './tracer'
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConsoleLogger } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
await tracer.start();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: new ConsoleLogger({ json: true }),
|
||||
});
|
||||
|
||||
@@ -34,4 +34,8 @@ export class TourStateMetaObjectType {
|
||||
count!: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
revenueSum?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
positiveRevenueDeviation?: number;
|
||||
@Field(() => Float, { nullable: true })
|
||||
negativeRevenueDeviation?: number;
|
||||
}
|
||||
|
||||
+32
-1
@@ -170,7 +170,12 @@ export class AccountingValidationRepository {
|
||||
],
|
||||
};
|
||||
|
||||
const [count, revenueSum] = await this.prisma.$transaction([
|
||||
const [
|
||||
count,
|
||||
revenueSum,
|
||||
positiveRevenueDeviation,
|
||||
negativeRevenueDeviation,
|
||||
] = await this.prisma.$transaction([
|
||||
this.prisma.tour.count({
|
||||
where: { ...ticketWithCurrentStateAndTours },
|
||||
}),
|
||||
@@ -182,11 +187,37 @@ export class AccountingValidationRepository {
|
||||
...ticketWithCurrentStateAndTours,
|
||||
},
|
||||
}),
|
||||
this.prisma.tour.aggregate({
|
||||
_sum: {
|
||||
revenueDeviation: true,
|
||||
},
|
||||
where: {
|
||||
...ticketWithCurrentStateAndTours,
|
||||
revenueDeviation: {
|
||||
gt: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.tour.aggregate({
|
||||
_sum: {
|
||||
revenueDeviation: true,
|
||||
},
|
||||
where: {
|
||||
...ticketWithCurrentStateAndTours,
|
||||
revenueDeviation: {
|
||||
lt: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
count,
|
||||
revenueSum: revenueSum._sum.revenue ?? 0,
|
||||
positiveRevenueDeviation:
|
||||
positiveRevenueDeviation._sum.revenueDeviation ?? 0,
|
||||
negativeRevenueDeviation:
|
||||
negativeRevenueDeviation._sum.revenueDeviation ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -6,7 +6,7 @@ import {
|
||||
} from './accounting-arg.types';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { AccountingValidationService } from './accounting-validation.service';
|
||||
import { PaginatedTourObjectType } from './accounting.object-type';
|
||||
import { PaginatedTourObjectType, TourCostBreakdownObjectType } from './accounting.object-type';
|
||||
import { TourObjectType } from './tour.object-type';
|
||||
|
||||
@Resolver(() => TourObjectType)
|
||||
@@ -28,6 +28,11 @@ export class AccountingValidationResolver {
|
||||
return this.service.getAccountingStateMeta(accountingState);
|
||||
}
|
||||
|
||||
@Query(() => TourCostBreakdownObjectType)
|
||||
tourCostBreakdown(@Args('operationId') operationId: string) {
|
||||
return this.service.getTourCostBreakdown(operationId)
|
||||
}
|
||||
|
||||
@ResolveField(() => TicketObjectType, { nullable: true })
|
||||
ticket(@Parent() tour: TourObjectType) {
|
||||
return this.service.findTicketByTicketId(tour.ticketId);
|
||||
|
||||
+13
-5
@@ -1,17 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
decodeOffsetCursor,
|
||||
encodeCursor,
|
||||
} from 'src/core/base/pagination.util';
|
||||
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';
|
||||
import { AccountingService } from './accounting.service';
|
||||
|
||||
@Injectable()
|
||||
export class AccountingValidationService {
|
||||
constructor(private readonly repository: AccountingValidationRepository) {}
|
||||
constructor(
|
||||
private readonly repository: AccountingValidationRepository,
|
||||
private readonly accounting: AccountingService,
|
||||
) {}
|
||||
|
||||
async paginateToursByState(
|
||||
state: AccountingState,
|
||||
@@ -49,4 +53,8 @@ export class AccountingValidationService {
|
||||
if (!ticketId) return;
|
||||
return this.repository.findTicketByTicketId(ticketId);
|
||||
}
|
||||
|
||||
async getTourCostBreakdown(operationId: string) {
|
||||
return this.accounting.tourCostBreakdown(operationId);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -1,11 +1,33 @@
|
||||
import { ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { AccountingState } from './accounting-state';
|
||||
import { Paginated } from 'src/core/base/pagination.input';
|
||||
import { TourObjectType } from './tour.object-type';
|
||||
|
||||
@ObjectType('PaginatedTour')
|
||||
export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
|
||||
import { CostTypeVariant } from '@prisma/client';
|
||||
|
||||
registerEnumType(AccountingState, {
|
||||
name: 'AccountingState',
|
||||
});
|
||||
|
||||
@ObjectType('PaginatedTour')
|
||||
export class PaginatedTourObjectType extends Paginated(TourObjectType) {}
|
||||
|
||||
@ObjectType('ServicePosition')
|
||||
export class ServicePositionObjectType {
|
||||
@Field()
|
||||
variant: CostTypeVariant;
|
||||
@Field()
|
||||
description: string;
|
||||
@Field(() => Float)
|
||||
value: number;
|
||||
}
|
||||
|
||||
@ObjectType('TourCostBreakdown')
|
||||
export class TourCostBreakdownObjectType {
|
||||
@Field()
|
||||
tariffDescription: string;
|
||||
@Field()
|
||||
tariffValidityPeriod: string;
|
||||
|
||||
@Field(() => [ServicePositionObjectType])
|
||||
servicePositions: ServicePositionObjectType[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { format } from 'date-fns';
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||
import { TourCostBreakdownObjectType } from './accounting.object-type';
|
||||
|
||||
@Injectable()
|
||||
export class AccountingService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly calculation: CalculationService,
|
||||
) {}
|
||||
|
||||
async tourCostBreakdown(
|
||||
operationId: string,
|
||||
): Promise<TourCostBreakdownObjectType> {
|
||||
const tour = await this.prisma.tour.findFirst({
|
||||
where: { operationId },
|
||||
});
|
||||
|
||||
const { tariff, servicePositions } =
|
||||
await this.calculation.listProvidedServicePositions(tour);
|
||||
|
||||
if (!tariff || servicePositions.length === 0) {
|
||||
return {
|
||||
tariffDescription: '!-- Tarif kann nicht ermittelt werden --!',
|
||||
tariffValidityPeriod: '--',
|
||||
servicePositions: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tariffDescription: tariff.tariffGroup?.description,
|
||||
tariffValidityPeriod: this.formatTariffValidityPeriod(
|
||||
tariff.validFrom,
|
||||
tariff.validTo,
|
||||
),
|
||||
servicePositions: servicePositions.map(([costType, value]) => ({
|
||||
value,
|
||||
variant: costType.variant,
|
||||
description: costType.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private formatTariffValidityPeriod(from: Date, to?: Date): string {
|
||||
if (!to) return `ab ${format(new Date(from), 'yyyy-MM-dd')}`;
|
||||
return `${format(new Date(from), 'yyyy-MM-dd')} - ${format(new Date(to), 'yyyy-MM-dd')}`;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { AccountingValidationRepository } from './accounting-validation.repository';
|
||||
import { AccountingValidationResolver } from './accounting-validation.resolver';
|
||||
import { AccountingValidationService } from './accounting-validation.service';
|
||||
import { AccountingService } from './accounting.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, FeatCalculationModule],
|
||||
providers: [
|
||||
AccountingValidationRepository,
|
||||
AccountingValidationResolver,
|
||||
AccountingValidationService,
|
||||
AccountingService,
|
||||
],
|
||||
exports: [],
|
||||
})
|
||||
|
||||
+2
-1
@@ -2,9 +2,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';
|
||||
import { FeatKpiModule } from '../feat-kpis/feat-kpis.module';
|
||||
|
||||
@Module({
|
||||
imports: [AnomalyDetectionModule],
|
||||
imports: [AnomalyDetectionModule, FeatKpiModule],
|
||||
providers: [ControlCenterResolver, SolutionTargetsResolver],
|
||||
})
|
||||
export class ApplicationControlCenterModule {}
|
||||
|
||||
+7
-1
@@ -21,10 +21,16 @@ import {
|
||||
PaginatedAnomalyObjectType,
|
||||
SolutionTargetObjectType,
|
||||
} from './anomaly.object-type';
|
||||
import { KpiService } from '../feat-kpis/kpi.service';
|
||||
|
||||
@Resolver(() => AnomalyObjectType)
|
||||
export class ControlCenterResolver {
|
||||
constructor(private readonly anomaliesService: AnomaliesService) {}
|
||||
constructor(private readonly anomaliesService: AnomaliesService, private readonly kpiService: KpiService) {}
|
||||
|
||||
@Query(() => [TourObjectType])
|
||||
async upcomingTours() {
|
||||
return this.kpiService.upcomingTours();
|
||||
}
|
||||
|
||||
@Query(() => AnomalyObjectType)
|
||||
async anomaly(@Args('id') id: string) {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { HomeResolver } from './home.resolver';
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [HomeResolver, HomeService],
|
||||
})
|
||||
export class AppHomeModule {}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('RevenueTrend')
|
||||
export class RevenueTrendObjectType {
|
||||
@Field(() => Date)
|
||||
date: Date;
|
||||
@Field()
|
||||
ordinanceType: string;
|
||||
@Field(() => Float)
|
||||
revenueSum: number;
|
||||
}
|
||||
|
||||
@ObjectType('HomeDashboardKPIs')
|
||||
export class HomeDashboardKPIsObjectType {
|
||||
@Field(() => Int)
|
||||
anomalyCount: number;
|
||||
@Field(() => Int)
|
||||
employeesWithoutTourCount: number;
|
||||
@Field(() => Int)
|
||||
toursWithoutApprovalCount: number;
|
||||
@Field(() => Int)
|
||||
billableTourCount: number;
|
||||
@Field(() => Float)
|
||||
billableTourRevenueSum: number;
|
||||
@Field(() => Int)
|
||||
billedTourCount: number;
|
||||
@Field(() => Float)
|
||||
billedTourPositiveRevenueDeviation: number;
|
||||
@Field(() => Float)
|
||||
billedTourNegativeRevenueDeviation: number;
|
||||
@Field(() => [RevenueTrendObjectType])
|
||||
revenueTrend: RevenueTrendObjectType[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
import { HomeDashboardKPIsObjectType } from './home.object-type';
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@Resolver()
|
||||
export class HomeResolver {
|
||||
constructor(private readonly service: HomeService) {}
|
||||
|
||||
@Query(() => HomeDashboardKPIsObjectType)
|
||||
homeDashboardKpis() {
|
||||
return this.service.homeDashboardKpis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ApprovalState, Prisma, Tour } from '@prisma/client';
|
||||
import { addDays, endOfDay, format, startOfDay, subDays } from 'date-fns';
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { AnomalyTypes } from '../feat-anomalies/anomaly-detectors/anomaly-types.enum';
|
||||
import {
|
||||
HomeDashboardKPIsObjectType,
|
||||
RevenueTrendObjectType,
|
||||
} from './home.object-type';
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async homeDashboardKpis(): Promise<HomeDashboardKPIsObjectType> {
|
||||
const anomalyCount = await this.getAnomalyCount();
|
||||
const employeesWithoutTourCount = await this.getEmployeesWithoutTourCount();
|
||||
const toursWithoutApprovalCount = await this.getToursWithoutApprovalCount();
|
||||
const billableTourCount = await this.getBillableTourCount();
|
||||
const billableTourRevenueSum = await this.getBillableTourRevenueSum();
|
||||
const {
|
||||
tourCount: billedTourCount,
|
||||
positiveRevenueDeviation: billedTourPositiveRevenueDeviation,
|
||||
negativeRevenueDeviation: billedTourNegativeRevenueDeviation,
|
||||
} = await this.getBilledTourKPIs();
|
||||
const revenueTrend = await this.getRevenueTrend();
|
||||
|
||||
return {
|
||||
anomalyCount,
|
||||
employeesWithoutTourCount,
|
||||
toursWithoutApprovalCount,
|
||||
billableTourCount,
|
||||
billableTourRevenueSum,
|
||||
billedTourCount,
|
||||
billedTourPositiveRevenueDeviation,
|
||||
billedTourNegativeRevenueDeviation,
|
||||
revenueTrend,
|
||||
};
|
||||
}
|
||||
|
||||
private async getAnomalyCount(): Promise<number> {
|
||||
return await this.prisma.anomaly.count({
|
||||
where: {
|
||||
resolvedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getEmployeesWithoutTourCount(): Promise<number> {
|
||||
return await this.prisma.anomaly.count({
|
||||
where: {
|
||||
type: AnomalyTypes.WorkingEmployeeNotInDistributionSystem,
|
||||
resolvedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getToursWithoutApprovalCount(): Promise<number> {
|
||||
return await this.prisma.tour.count({
|
||||
where: {
|
||||
startDate: {
|
||||
gte: startOfDay(addDays(new Date(), 3)),
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
Ticket: {
|
||||
approvalState: ApprovalState.UNKNOWN,
|
||||
},
|
||||
},
|
||||
{
|
||||
Ticket: {
|
||||
approvalState: ApprovalState.REQUIRED,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getBillableTourCount(): Promise<number> {
|
||||
return await this.prisma.tour.count({
|
||||
where: {
|
||||
done: true,
|
||||
check: 0,
|
||||
empty: { not: null },
|
||||
ticketId: { not: null },
|
||||
occupiedKm: {
|
||||
lt: 9000,
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async getBillableTourRevenueSum(): Promise<number> {
|
||||
const aggr = await this.prisma.tour.aggregate({
|
||||
_sum: {
|
||||
revenue: true,
|
||||
},
|
||||
where: {
|
||||
done: true,
|
||||
check: 0,
|
||||
empty: { not: null },
|
||||
ticketId: { not: null },
|
||||
occupiedKm: {
|
||||
lt: 9000,
|
||||
},
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return aggr._sum.revenue;
|
||||
}
|
||||
|
||||
private async getBilledTourKPIs(): Promise<{
|
||||
tourCount: number;
|
||||
positiveRevenueDeviation: number;
|
||||
negativeRevenueDeviation: number;
|
||||
}> {
|
||||
const toursWithCurrentState: Prisma.TourWhereInput = {
|
||||
check: 2,
|
||||
revenueDeviation: { not: 0 },
|
||||
empty: {
|
||||
not: null,
|
||||
},
|
||||
ticketId: {
|
||||
not: null,
|
||||
},
|
||||
occupiedKm: {
|
||||
lt: 9000,
|
||||
},
|
||||
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 [tourCount, positiveRevenueDeviation, negativeRevenueDeviation] =
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.tour.count({
|
||||
where: { ...toursWithCurrentState },
|
||||
}),
|
||||
this.prisma.tour.aggregate({
|
||||
_sum: {
|
||||
revenueDeviation: true,
|
||||
},
|
||||
where: {
|
||||
...toursWithCurrentState,
|
||||
revenueDeviation: {
|
||||
gt: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.tour.aggregate({
|
||||
_sum: {
|
||||
revenueDeviation: true,
|
||||
},
|
||||
where: {
|
||||
...toursWithCurrentState,
|
||||
revenueDeviation: {
|
||||
lt: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
tourCount,
|
||||
positiveRevenueDeviation:
|
||||
positiveRevenueDeviation._sum.revenueDeviation ?? 0,
|
||||
negativeRevenueDeviation:
|
||||
negativeRevenueDeviation._sum.revenueDeviation ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async getRevenueTrend(): Promise<RevenueTrendObjectType[]> {
|
||||
const tours = await this.getTours();
|
||||
|
||||
const toursPerDay = tours.reduce(
|
||||
(acc, tour) => {
|
||||
const tourDay = format(new Date(tour.startDate), 'yyyy-MM-dd');
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[tourDay]: [...(acc[tourDay]?.length ? acc[tourDay] : []), tour],
|
||||
};
|
||||
},
|
||||
{} as Record<string, Tour[]>,
|
||||
) as Record<string, Tour[]>;
|
||||
|
||||
const revenueTrend = Object.entries(toursPerDay).flatMap(([day, tours]) => {
|
||||
const toursByOrdinanceType = tours.reduce(
|
||||
(acc, tour) => {
|
||||
return {
|
||||
...acc,
|
||||
[tour.ordinanceType]: [
|
||||
...(acc[tour.ordinanceType] ? acc[tour.ordinanceType] : []),
|
||||
tour,
|
||||
],
|
||||
};
|
||||
},
|
||||
{} as Record<string, Tour[]>,
|
||||
);
|
||||
return Object.entries(toursByOrdinanceType).map(([ot, tours]) => {
|
||||
const revenueSum = tours.reduce(
|
||||
(sum, { revenue }) => (sum += revenue),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
revenueSum,
|
||||
date: new Date(day),
|
||||
ordinanceType: ot,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return revenueTrend;
|
||||
}
|
||||
|
||||
private getTours(): Promise<Tour[]> {
|
||||
return this.prisma.tour.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
startDate: {
|
||||
gte: startOfDay(subDays(new Date(), 10)),
|
||||
lte: endOfDay(new Date()),
|
||||
},
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{ordinanceType: {not: null}},
|
||||
{ordinanceType: {not: 'Tragehilfe'}},
|
||||
]
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
carName: null,
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
carName: {
|
||||
not: 'Storno',
|
||||
},
|
||||
},
|
||||
{
|
||||
carName: {
|
||||
not: 'Löschen',
|
||||
},
|
||||
},
|
||||
{
|
||||
carName: {
|
||||
not: 'Abgabe',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
check: {
|
||||
not: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
occupiedKm: {
|
||||
lt: 9999,
|
||||
},
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
patientId: null,
|
||||
},
|
||||
{
|
||||
patientId: {
|
||||
// Pause
|
||||
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||
},
|
||||
},
|
||||
{
|
||||
patientId: {
|
||||
// Feierabend
|
||||
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
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 { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { ManagerResolver } from './manager.resolver';
|
||||
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';
|
||||
import { TourAmountForecastService } from './tour-amount-forecast.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, FeatureCalculationModule],
|
||||
imports: [PrismaModule, FeatCalculationModule, FeatBusinessObjectsModule],
|
||||
providers: [
|
||||
TourAmountForecastService,
|
||||
TimePerTourKpiService,
|
||||
TimePerTourMetricsService,
|
||||
CarEntityService,
|
||||
CarEntityRepository,
|
||||
ManagerResolver,
|
||||
],
|
||||
})
|
||||
export class ApplicationManagerModule {}
|
||||
export class ManagerModule {}
|
||||
|
||||
+1
-1
@@ -3,10 +3,10 @@ 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';
|
||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||
|
||||
@Injectable()
|
||||
export class TimePerTourKpiService {
|
||||
|
||||
+1
-1
@@ -15,11 +15,11 @@ import {
|
||||
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';
|
||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||
|
||||
@Injectable()
|
||||
export class TimePerTourMetricsService {
|
||||
|
||||
+33
-6
@@ -112,12 +112,28 @@ export class TicketLifecycleService {
|
||||
: ApprovalState.REQUIRED,
|
||||
});
|
||||
} else {
|
||||
await this.repository.updateTicket(ticket.id, {
|
||||
errors,
|
||||
notes,
|
||||
state: TicketValidationState.USER_INTERACTION_REQUIRED,
|
||||
approvalState: ApprovalState.FREE,
|
||||
});
|
||||
// NOTE: Condition required to get these tickets out of USER_INTERACTION_REQUIRED
|
||||
if (
|
||||
ticket.tours.every(
|
||||
({ ordinanceType }) =>
|
||||
ordinanceType.indexOf('BTW') > -1 ||
|
||||
ordinanceType.indexOf('TSW') > -1,
|
||||
)
|
||||
) {
|
||||
await this.repository.updateTicket(ticket.id, {
|
||||
errors,
|
||||
notes,
|
||||
state: TicketValidationState.TD_UNCERTAIN,
|
||||
approvalState: ApprovalState.FREE,
|
||||
});
|
||||
} 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);
|
||||
@@ -186,6 +202,9 @@ export class TicketLifecycleService {
|
||||
const approvalRequirements = await Promise.all(
|
||||
ticket.tours.map((tour) => this.checkTourApprovalRequirement(tour)),
|
||||
);
|
||||
if(ticket.tours.some(({ordinanceType}) => ordinanceType.indexOf('TSW') > -1)) {
|
||||
console.log(approvalRequirements)
|
||||
}
|
||||
const isApprovalRequired = approvalRequirements.reduce(
|
||||
(result, requirement) => result || requirement,
|
||||
false,
|
||||
@@ -204,6 +223,14 @@ export class TicketLifecycleService {
|
||||
}
|
||||
|
||||
private async checkTourApprovalRequirement(tour: Tour): Promise<boolean> {
|
||||
// NOTE: Requirement by Avicenna
|
||||
if (
|
||||
tour.ordinanceType.indexOf('BTW') > -1 ||
|
||||
tour.ordinanceType.indexOf('TSW') > -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'Einweisung',
|
||||
|
||||
+9
-9
@@ -27,15 +27,15 @@ export function validatePatient(patient: Patient): ValidationResult {
|
||||
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');
|
||||
}
|
||||
// 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,
|
||||
|
||||
+2
-2
@@ -200,11 +200,11 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
|
||||
return false;
|
||||
}
|
||||
if (!employee.personnelNumber) {
|
||||
this.logger.warn(`Employee has no personnelNumber`, {
|
||||
this.logger.warn(`Employee has no personnelNumber ${JSON.stringify({
|
||||
id: employee.id,
|
||||
surname: employee.surname,
|
||||
name: employee.name,
|
||||
});
|
||||
})}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
+8
-1
@@ -86,9 +86,16 @@ export class WorkingEmployeeNotInDistributionSystemDetector extends AnomalyDetec
|
||||
const employeeId = employeeIdParts.join('-');
|
||||
const employee = await this.dataService.findEmployeeById(employeeId);
|
||||
|
||||
if (!employee || !employee?.personnelNumber) {
|
||||
this.logger.error(
|
||||
`Cannot verify anomaly ${anomaly.id}. Employee (${employeeId}) does not exist or cannot be synced ${JSON.stringify(employee)}`,
|
||||
);
|
||||
return anomaly;
|
||||
}
|
||||
|
||||
const plannedTime =
|
||||
await this.dataService.findPlannedTimeByPersonnelNumberForDay(
|
||||
`${employee.personnelNumber}`,
|
||||
`${employee?.personnelNumber}`,
|
||||
attendanceRegistration.dateTime,
|
||||
);
|
||||
if (
|
||||
|
||||
+14
-5
@@ -98,11 +98,15 @@ export class CarEntityRepository {
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
department: {
|
||||
contains: ordinanceType,
|
||||
},
|
||||
},
|
||||
...(ordinanceType !== 'ALL'
|
||||
? [
|
||||
{
|
||||
department: {
|
||||
contains: ordinanceType,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
department: {
|
||||
not: 'Leitstelle',
|
||||
@@ -113,6 +117,11 @@ export class CarEntityRepository {
|
||||
not: 'Büro',
|
||||
},
|
||||
},
|
||||
{
|
||||
department: {
|
||||
not: 'Fortbildung',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
+1
-1
@@ -138,7 +138,7 @@ export class CarEntity {
|
||||
}`;
|
||||
}
|
||||
|
||||
private plannedTimeIntervalInInterval(interval: Interval): Interval {
|
||||
public plannedTimeIntervalInInterval(interval: Interval): Interval {
|
||||
return this.plannedTimes
|
||||
.map(({ startDate, endDate }) => ({
|
||||
start: new Date(startDate),
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "src/core/database/prisma.module";
|
||||
import { CarEntityRepository } from "./car-entity/car-entity.repository";
|
||||
import { CarEntityService } from "./car-entity/car-entity.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
exports: [CarEntityService],
|
||||
providers: [CarEntityRepository, CarEntityService]
|
||||
})
|
||||
export class FeatBusinessObjectsModule {}
|
||||
+89
-146
@@ -6,22 +6,21 @@ import {
|
||||
TariffGroup,
|
||||
Tour,
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
Interval,
|
||||
addDays,
|
||||
isSameDay,
|
||||
isSaturday,
|
||||
isWeekend,
|
||||
isWithinInterval,
|
||||
} from 'date-fns';
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
import { Interval, addDays, isSaturday, isWithinInterval } from 'date-fns';
|
||||
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
import { BILLING_INFORMATION_BY_ORDINANCE_TYPE } from './billing-information-by-ordinance-type';
|
||||
import { INFECTION_TYPES } from './infection-types';
|
||||
import {
|
||||
calculateDesinfektionszuschlag,
|
||||
calculateFahrzeugzuschlag,
|
||||
calculateFeiertagszuschlag,
|
||||
calculateKilometerzuschlag,
|
||||
calculateNachtzuschlag,
|
||||
calculateTSW,
|
||||
calculateWochenendzuschlag,
|
||||
} from './cost-type-calculations';
|
||||
import { PersonnelCostCalculationService } from './personnel-cost-calculation.service';
|
||||
import { PUBLIC_HOLIDAYS_HH } from './public-holidays';
|
||||
|
||||
enum OrdinanceTypes {
|
||||
BTW = 'BTW',
|
||||
@@ -56,10 +55,39 @@ export class CalculationService {
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const revenue = this.calculateRevenue(tour, tariff) / 100;
|
||||
const revenue =
|
||||
this.calculateProvidedServicePositions(tour, tariff).reduce(
|
||||
(sum, [, value]) => (sum += value),
|
||||
0,
|
||||
) / 100;
|
||||
return revenue;
|
||||
}
|
||||
|
||||
async listProvidedServicePositions(tour: Tour): Promise<{
|
||||
tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup };
|
||||
servicePositions: [CostType, number][];
|
||||
}> {
|
||||
const { healthInsurance, ordinanceType, startDate } = tour;
|
||||
if (!ordinanceType) {
|
||||
return { tariff: undefined, servicePositions: [] };
|
||||
}
|
||||
const tariff = await this.findTariff(
|
||||
healthInsurance,
|
||||
ordinanceType.match(/([A-Z])\w+/).at(0),
|
||||
new Date(startDate),
|
||||
);
|
||||
if (!tariff) {
|
||||
this.logger.warn(
|
||||
`No tariff found for tour ${tour.operationId} (${healthInsurance}, ${ordinanceType}, ${startDate})`,
|
||||
);
|
||||
return { tariff: undefined, servicePositions: [] };
|
||||
}
|
||||
return {
|
||||
tariff,
|
||||
servicePositions: this.calculateProvidedServicePositions(tour, tariff),
|
||||
};
|
||||
}
|
||||
|
||||
public calculatePersonnelCostInInterval(
|
||||
interval: Interval,
|
||||
employeeAmount: number,
|
||||
@@ -172,50 +200,58 @@ export class CalculationService {
|
||||
return billingInformation[1];
|
||||
}
|
||||
|
||||
private calculateRevenue(
|
||||
private calculateProvidedServicePositions(
|
||||
tour: Tour,
|
||||
tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup },
|
||||
): number {
|
||||
): [CostType, number][] {
|
||||
const highestZuschlag = this.getHighestZuschlag(tour, tariff);
|
||||
// if TariffGroup is VDEK KTW, sum up costTypes
|
||||
const isVdek = tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1;
|
||||
|
||||
return tariff.costTypes.reduce((sum, costType) => {
|
||||
switch (costType.variant) {
|
||||
case CostTypeVariant.GRUNDPREIS:
|
||||
// TODO: decide if you need a CostTypeVariant for QMS Zuschlag and exclude the category there
|
||||
if (
|
||||
costType.description.indexOf('QMS') > -1 &&
|
||||
tour.category === 'Privatfahrt Rechnung'
|
||||
) {
|
||||
return sum;
|
||||
}
|
||||
return sum + costType.costAmount;
|
||||
case CostTypeVariant.TSWDIFFERENZIERUNG:
|
||||
return sum + this.calculateTSW(tour, costType);
|
||||
case CostTypeVariant.DESINFEKTIONSZUSCHLAG:
|
||||
return sum + this.calculateDesinfektionszuschlag(tour, costType);
|
||||
case CostTypeVariant.KILOMETERZUSCHLAG:
|
||||
return sum + this.calculateKilometerzuschlag(tour, costType);
|
||||
case CostTypeVariant.FAHRZEUGZUSCHLAG:
|
||||
return sum + this.calculateFahrzeugzuschlag(tour, costType);
|
||||
case CostTypeVariant.NACHTZUSCHLAG:
|
||||
if (highestZuschlag === CostTypeVariant.NACHTZUSCHLAG || isVdek) {
|
||||
return sum + this.calculateNachtzuschlag(tour, costType);
|
||||
}
|
||||
return sum;
|
||||
case CostTypeVariant.WOCHENENDZUSCHLAG:
|
||||
if (highestZuschlag === CostTypeVariant.WOCHENENDZUSCHLAG || isVdek) {
|
||||
return sum + this.calculateWochenendzuschlag(tour, costType);
|
||||
}
|
||||
return sum;
|
||||
case CostTypeVariant.FEIERTAGSZUSCHLAG:
|
||||
if (highestZuschlag === CostTypeVariant.FEIERTAGSZUSCHLAG || isVdek) {
|
||||
return sum + this.calculateFeiertagszuschlag(tour, costType);
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}, 0);
|
||||
return tariff.costTypes
|
||||
.map((costType) => {
|
||||
switch (costType.variant) {
|
||||
case CostTypeVariant.GRUNDPREIS:
|
||||
// TODO: decide if you need a CostTypeVariant for QMS Zuschlag and exclude the category there
|
||||
if (
|
||||
costType.description.indexOf('QMS') > -1 &&
|
||||
tour.category === 'Privatfahrt Rechnung'
|
||||
) {
|
||||
return [costType, 0];
|
||||
}
|
||||
return [costType, costType.costAmount];
|
||||
case CostTypeVariant.TSWDIFFERENZIERUNG:
|
||||
return [costType, calculateTSW(tour, costType)];
|
||||
case CostTypeVariant.DESINFEKTIONSZUSCHLAG:
|
||||
return [costType, calculateDesinfektionszuschlag(tour, costType)];
|
||||
case CostTypeVariant.KILOMETERZUSCHLAG:
|
||||
return [costType, calculateKilometerzuschlag(tour, costType)];
|
||||
case CostTypeVariant.FAHRZEUGZUSCHLAG:
|
||||
return [costType, calculateFahrzeugzuschlag(tour, costType)];
|
||||
case CostTypeVariant.NACHTZUSCHLAG:
|
||||
if (highestZuschlag === CostTypeVariant.NACHTZUSCHLAG || isVdek) {
|
||||
return [costType, calculateNachtzuschlag(tour, costType)];
|
||||
}
|
||||
return [costType, 0];
|
||||
case CostTypeVariant.WOCHENENDZUSCHLAG:
|
||||
if (
|
||||
highestZuschlag === CostTypeVariant.WOCHENENDZUSCHLAG ||
|
||||
isVdek
|
||||
) {
|
||||
return [costType, calculateWochenendzuschlag(tour, costType)];
|
||||
}
|
||||
return [costType, 0];
|
||||
case CostTypeVariant.FEIERTAGSZUSCHLAG:
|
||||
if (
|
||||
highestZuschlag === CostTypeVariant.FEIERTAGSZUSCHLAG ||
|
||||
isVdek
|
||||
) {
|
||||
return [costType, calculateFeiertagszuschlag(tour, costType)];
|
||||
}
|
||||
return [costType, 0];
|
||||
}
|
||||
})
|
||||
.filter(([, value]) => value !== 0) as [CostType, number][];
|
||||
}
|
||||
|
||||
private getHighestZuschlag(
|
||||
@@ -235,17 +271,17 @@ export class CalculationService {
|
||||
case CostTypeVariant.NACHTZUSCHLAG:
|
||||
return [
|
||||
CostTypeVariant.NACHTZUSCHLAG,
|
||||
this.calculateNachtzuschlag(tour, costType),
|
||||
calculateNachtzuschlag(tour, costType),
|
||||
];
|
||||
case CostTypeVariant.WOCHENENDZUSCHLAG:
|
||||
return [
|
||||
CostTypeVariant.WOCHENENDZUSCHLAG,
|
||||
this.calculateWochenendzuschlag(tour, costType),
|
||||
calculateWochenendzuschlag(tour, costType),
|
||||
];
|
||||
case CostTypeVariant.FEIERTAGSZUSCHLAG:
|
||||
return [
|
||||
CostTypeVariant.FEIERTAGSZUSCHLAG,
|
||||
this.calculateFeiertagszuschlag(tour, costType),
|
||||
calculateFeiertagszuschlag(tour, costType),
|
||||
];
|
||||
}
|
||||
})
|
||||
@@ -260,99 +296,6 @@ export class CalculationService {
|
||||
return zuschlaegeHighestToLowest[0][0];
|
||||
}
|
||||
|
||||
private calculateDesinfektionszuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
if (tour.hasInfection && !tour.infectionName) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
tour.hasInfection &&
|
||||
(tour.infectionName.indexOf(costType.infectionType) > -1 ||
|
||||
INFECTION_TYPES[tour.infectionName] === costType.infectionType)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private calculateTSW(tour: Tour, costType: CostType): number {
|
||||
if (
|
||||
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('TS 2') ||
|
||||
costType.description.includes('TSW 2')) &&
|
||||
tour.driverId !== tour.codriverId
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private calculateFeiertagszuschlag(tour: Tour, costType: CostType): number {
|
||||
if (
|
||||
PUBLIC_HOLIDAYS_HH.some((day) =>
|
||||
isSameDay(
|
||||
new Date(day),
|
||||
new Date(toZonedTime(tour.startDate, 'Europe/Berlin')),
|
||||
),
|
||||
)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private calculateNachtzuschlag(tour: Tour, costType: CostType): number {
|
||||
const firstInterval = generateIntervalForTimeRange(
|
||||
costType.timeRange,
|
||||
'Europe/Berlin',
|
||||
addDays(tour.startDate, -1),
|
||||
);
|
||||
const secondInterval = generateIntervalForTimeRange(
|
||||
costType.timeRange,
|
||||
'Europe/Berlin',
|
||||
tour.startDate,
|
||||
);
|
||||
if (
|
||||
isWithinInterval(tour.startDate, firstInterval) ||
|
||||
isWithinInterval(tour.startDate, secondInterval)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private calculateKilometerzuschlag(tour: Tour, costType: CostType): number {
|
||||
const kilometerOverLimit = Math.ceil(
|
||||
tour.occupiedKm - costType.kmInclusive,
|
||||
);
|
||||
if (kilometerOverLimit <= 0) return 0;
|
||||
return costType.costAmount * kilometerOverLimit;
|
||||
}
|
||||
|
||||
private calculateFahrzeugzuschlag(tour: Tour, costType: CostType): number {
|
||||
const isCarNameSupported = !costType.excludeCarNames.includes(tour.carName);
|
||||
if (!isCarNameSupported) return 0;
|
||||
return costType.costAmount;
|
||||
}
|
||||
|
||||
private calculateWochenendzuschlag(tour: Tour, costType: CostType): number {
|
||||
if (isWeekend(toZonedTime(tour.startDate, 'Europe/Berlin')))
|
||||
return costType.costAmount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async findTariff(
|
||||
healthInsurance: string,
|
||||
ordinanceType: string,
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { CostType, Tour } from '@prisma/client';
|
||||
import { addDays, isSameDay, isWeekend, isWithinInterval } from 'date-fns';
|
||||
import { toZonedTime } from 'date-fns-tz';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
import { INFECTION_TYPES } from './infection-types';
|
||||
import { PUBLIC_HOLIDAYS_HH } from './public-holidays';
|
||||
|
||||
export function calculateDesinfektionszuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
if (tour.hasInfection && !tour.infectionName) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
tour.hasInfection &&
|
||||
(tour.infectionName.indexOf(costType.infectionType) > -1 ||
|
||||
INFECTION_TYPES[tour.infectionName] === costType.infectionType)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateTSW(tour: Tour, costType: CostType): number {
|
||||
if (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.transportType === 'tsw'
|
||||
) {
|
||||
return costType.costAmount;
|
||||
} else if (
|
||||
(costType.description.includes('TS 2') ||
|
||||
costType.description.includes('TSW 2')) &&
|
||||
tour.transportType === 'TS+T'
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateFeiertagszuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
if (
|
||||
PUBLIC_HOLIDAYS_HH.some((day) =>
|
||||
isSameDay(
|
||||
new Date(day),
|
||||
new Date(toZonedTime(tour.startDate, 'Europe/Berlin')),
|
||||
),
|
||||
)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateNachtzuschlag(tour: Tour, costType: CostType): number {
|
||||
const firstInterval = generateIntervalForTimeRange(
|
||||
costType.timeRange,
|
||||
'Europe/Berlin',
|
||||
addDays(tour.startDate, -1),
|
||||
);
|
||||
const secondInterval = generateIntervalForTimeRange(
|
||||
costType.timeRange,
|
||||
'Europe/Berlin',
|
||||
tour.startDate,
|
||||
);
|
||||
if (
|
||||
isWithinInterval(tour.startDate, firstInterval) ||
|
||||
isWithinInterval(tour.startDate, secondInterval)
|
||||
) {
|
||||
return costType.costAmount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function calculateKilometerzuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
const kilometerOverLimit = Math.ceil(tour.occupiedKm - costType.kmInclusive);
|
||||
if (kilometerOverLimit <= 0) return 0;
|
||||
return costType.costAmount * kilometerOverLimit;
|
||||
}
|
||||
|
||||
export function calculateFahrzeugzuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
const isCarNameSupported = !costType.excludeCarNames.includes(tour.carName);
|
||||
if (!isCarNameSupported) return 0;
|
||||
return costType.costAmount;
|
||||
}
|
||||
|
||||
export function calculateWochenendzuschlag(
|
||||
tour: Tour,
|
||||
costType: CostType,
|
||||
): number {
|
||||
if (isWeekend(toZonedTime(tour.startDate, 'Europe/Berlin')))
|
||||
return costType.costAmount;
|
||||
return 0;
|
||||
}
|
||||
+1
-1
@@ -9,4 +9,4 @@ import { PersonnelCostCalculationService } from './personnel-cost-calculation.se
|
||||
providers: [CalculationService, PersonnelCostCalculationService],
|
||||
exports: [CalculationService],
|
||||
})
|
||||
export class FeatureCalculationModule {}
|
||||
export class FeatCalculationModule {}
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ export class TourLoader {
|
||||
|
||||
private static tourStagingToFinal(tour: TourStaging, ticketId?: string) {
|
||||
const {
|
||||
id,
|
||||
done,
|
||||
direction,
|
||||
carName,
|
||||
@@ -96,6 +97,7 @@ export class TourLoader {
|
||||
} = tour;
|
||||
|
||||
return {
|
||||
id,
|
||||
done,
|
||||
direction,
|
||||
carName,
|
||||
|
||||
+2
-2
@@ -1,13 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { FeatureCalculationModule } from 'src/modules/feat-calculation/feat-calculation.module';
|
||||
import { FeatCalculationModule } 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, FeatureCalculationModule],
|
||||
imports: [PrismaModule, DataAccessExtractorsModule, FeatCalculationModule],
|
||||
providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
|
||||
exports: [TransformersService],
|
||||
})
|
||||
|
||||
+3
-3
@@ -12,9 +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(),
|
||||
// surname: faker.person.lastName(),
|
||||
// name: faker.person.firstName(),
|
||||
// street: faker.location.streetAddress(),
|
||||
...transformProperties(parseDateTime('yyyy-MM-dd'))(
|
||||
pick(['birthday'])(patientSource),
|
||||
),
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ export class PlannedTimeTransformer {
|
||||
): PlannedTimePreStaging {
|
||||
const plannedTimeParsed = {
|
||||
...plannedTimeSource,
|
||||
firstname: faker.person.firstName(),
|
||||
surname: faker.person.lastName(),
|
||||
// firstname: faker.person.firstName(),
|
||||
// surname: faker.person.lastName(),
|
||||
...transformProperties(parseInteger)(
|
||||
pick(['id', 'user_id'])(plannedTimeSource),
|
||||
),
|
||||
|
||||
+10
-10
@@ -16,16 +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(),
|
||||
// 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),
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ export class WorkTimeTransformer {
|
||||
public static transform(workTimeSource: WorkTimeSource): WorkTimePreStaging {
|
||||
const workTimeParsed = {
|
||||
...workTimeSource,
|
||||
firstname: faker.person.firstName(),
|
||||
surname: faker.person.lastName(),
|
||||
// firstname: faker.person.firstName(),
|
||||
// surname: faker.person.lastName(),
|
||||
...transformProperties(parseInteger)(
|
||||
pick(['id', 'user_id'])(workTimeSource),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from 'src/core/database/prisma.module';
|
||||
import { FeatBusinessObjectsModule } from '../feat-business-objects/feat-business-objects.module';
|
||||
import { FeatCalculationModule } from '../feat-calculation/feat-calculation.module';
|
||||
import { KpiResolver } from './kpi.resolver';
|
||||
import { KpiService } from './kpi.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, FeatBusinessObjectsModule, FeatCalculationModule],
|
||||
providers: [KpiService, KpiResolver],
|
||||
exports: [KpiService],
|
||||
})
|
||||
export class FeatKpiModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
import { TicketValidationState } from '@prisma/client';
|
||||
import { KpiService } from './kpi.service';
|
||||
import { KpiInfoObjectType } from './object-types';
|
||||
|
||||
@Resolver()
|
||||
export class KpiResolver {
|
||||
constructor(private readonly kpiService: KpiService) {}
|
||||
|
||||
@Query(() => KpiInfoObjectType)
|
||||
ticketStateKpi(
|
||||
@Args('state', { type: () => TicketValidationState })
|
||||
ticketState: TicketValidationState,
|
||||
) {
|
||||
return this.kpiService.ticketStateKpi(ticketState);
|
||||
}
|
||||
|
||||
@Query(() => KpiInfoObjectType)
|
||||
managerKpi(
|
||||
@Args('ordinanceType', { type: () => String }) ordinanceType: string,
|
||||
) {
|
||||
return this.kpiService.managerKpi(ordinanceType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ApprovalState,
|
||||
Prisma,
|
||||
TicketValidationState,
|
||||
Tour,
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
add,
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
set,
|
||||
subDays,
|
||||
subHours,
|
||||
subMonths,
|
||||
} from 'date-fns';
|
||||
import { PrismaService } from 'src/core/database/prisma.service';
|
||||
import { generateIntervalForTimeRange } from 'src/utils';
|
||||
import { CarEntityService } from '../feat-business-objects/car-entity/car-entity.service';
|
||||
import { CalculationService } from '../feat-calculation/calculation.service';
|
||||
import { KpiInfoObjectType, MetricObjectType } from './object-types';
|
||||
|
||||
function ticketWithCurrentStateAndTours(
|
||||
ticketState: TicketValidationState,
|
||||
): Prisma.TicketWhereInput {
|
||||
return { currentState: ticketState, tours: { some: { check: 0 } } };
|
||||
}
|
||||
|
||||
function dateFromTime(timeString: string): Date {
|
||||
const [hours, minutes] = timeString.split(':');
|
||||
return set(new Date(), {
|
||||
hours: parseInt(hours),
|
||||
minutes: parseInt(minutes),
|
||||
seconds: 0,
|
||||
milliseconds: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KpiService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly carEntity: CarEntityService,
|
||||
private readonly calculationService: CalculationService,
|
||||
) {}
|
||||
|
||||
async calcCityTourAvgTime(ordinanceType: string) {
|
||||
const tourTimestamps = await this.prisma.tour.findMany({
|
||||
select: {
|
||||
startBegin: true,
|
||||
startEnd: true,
|
||||
target: true,
|
||||
targetBegin: true,
|
||||
empty: true,
|
||||
},
|
||||
where: {
|
||||
...(ordinanceType !== 'ALL'
|
||||
? {
|
||||
ordinanceType: {
|
||||
contains: ordinanceType,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
done: true,
|
||||
totalKm: {
|
||||
lte: 17,
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
const tourTimes = tourTimestamps
|
||||
.filter((timings) => Object.values(timings).filter(Boolean).length > 0)
|
||||
.map((timings) => {
|
||||
const times = Object.values(timings).filter(Boolean).sort();
|
||||
const startTime = times.at(0);
|
||||
const endTime = times.at(-1);
|
||||
return differenceInMinutes(
|
||||
dateFromTime(endTime),
|
||||
dateFromTime(startTime),
|
||||
);
|
||||
});
|
||||
return tourTimes.reduce((sum, cur) => (sum += cur), 0) / tourTimes.length;
|
||||
}
|
||||
|
||||
async calcCityTourAvgRevenue(ordinanceType: string) {
|
||||
const tourRevenues = await this.prisma.tour.findMany({
|
||||
select: {
|
||||
revenue: true,
|
||||
},
|
||||
where: {
|
||||
...(ordinanceType !== 'ALL'
|
||||
? {
|
||||
ordinanceType: {
|
||||
contains: ordinanceType,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
done: true,
|
||||
totalKm: {
|
||||
lte: 17,
|
||||
},
|
||||
},
|
||||
take: 5000,
|
||||
});
|
||||
return (
|
||||
tourRevenues.reduce((sum, { revenue }) => (sum += revenue), 0) /
|
||||
tourRevenues.length
|
||||
);
|
||||
}
|
||||
|
||||
upcomingTours(): Promise<Tour[]> {
|
||||
const now = new Date();
|
||||
const endUpcoming = add(now, { minutes: 20 });
|
||||
return this.prisma.tour.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
startDate: {
|
||||
gte: subHours(now, 4),
|
||||
},
|
||||
},
|
||||
{
|
||||
startDate: {
|
||||
lt: endUpcoming,
|
||||
},
|
||||
},
|
||||
{
|
||||
startBegin: null,
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
carName: null,
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
carName: {
|
||||
not: 'Storno',
|
||||
},
|
||||
},
|
||||
{
|
||||
carName: {
|
||||
not: 'Löschen',
|
||||
},
|
||||
},
|
||||
{
|
||||
carName: {
|
||||
not: 'Abgabe',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
{
|
||||
patientId: {
|
||||
// Pause
|
||||
not: 'b7424477-a4b2-42f7-b7e7-a4bef24f6979',
|
||||
},
|
||||
},
|
||||
{
|
||||
patientId: {
|
||||
// Feierabend
|
||||
not: '3554e1a9-9c07-45e5-b53c-692a77e4fa9a',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async managerKpi(ordinanceType: string): Promise<KpiInfoObjectType> {
|
||||
const avgRevenueCityTour = await this.calcCityTourAvgRevenue(ordinanceType);
|
||||
const avgCityTourTimeInMinutes =
|
||||
await this.calcCityTourAvgTime(ordinanceType);
|
||||
const forDay = subDays(new Date(), 0);
|
||||
const cars = await this.carEntity.getCarsForOperationDay(
|
||||
forDay,
|
||||
ordinanceType,
|
||||
);
|
||||
const workdayInterval = generateIntervalForTimeRange(
|
||||
'05:00-04:59',
|
||||
'Europe/Berlin',
|
||||
forDay,
|
||||
);
|
||||
|
||||
// console.log(cars.map(car => ))
|
||||
|
||||
const workCapacity = cars
|
||||
.map((car) => car.availableTimeInInterval(workdayInterval))
|
||||
.reduce((sum, cur) => (sum += cur), 0);
|
||||
|
||||
const tourAmount = cars
|
||||
.map((car) => car.tourAmountInInterval(workdayInterval))
|
||||
.reduce((sum, cur) => (sum += cur), 0);
|
||||
|
||||
const possibleRevenue =
|
||||
((workCapacity * 60) / avgCityTourTimeInMinutes) * avgRevenueCityTour;
|
||||
const carRevenues = await Promise.all([
|
||||
...cars.map((car) =>
|
||||
car.breakEvenInInterval(workdayInterval, this.calculationService),
|
||||
),
|
||||
]);
|
||||
const totalRevenue = carRevenues.reduce(
|
||||
(sum, { toursRevenue }) => (sum += toursRevenue),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
main: {
|
||||
label: 'Realisierbarer Umsatz',
|
||||
value: Math.floor(
|
||||
(100 / (possibleRevenue == 0 ? 1 : possibleRevenue)) * totalRevenue,
|
||||
),
|
||||
},
|
||||
secondary: {
|
||||
label: 'Realisierbare Touren',
|
||||
value:
|
||||
possibleRevenue > totalRevenue
|
||||
? (possibleRevenue - totalRevenue) / avgRevenueCityTour
|
||||
: 0,
|
||||
},
|
||||
additional: [
|
||||
{
|
||||
label: 'Anzahl Touren',
|
||||
value: tourAmount,
|
||||
},
|
||||
{
|
||||
label: 'Kapazität (in h)',
|
||||
value: workCapacity,
|
||||
},
|
||||
{
|
||||
label: '⌀ Zeit Stadttour (in min)',
|
||||
value: avgCityTourTimeInMinutes,
|
||||
},
|
||||
{
|
||||
label: '⌀ Umsatz Stadttour',
|
||||
value: avgRevenueCityTour,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async ticketStateKpi(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<KpiInfoObjectType> {
|
||||
return {
|
||||
main: await this.fastMovingTicketPercentMetric(ticketState),
|
||||
secondary: await this.averageTimePerStateMetric(ticketState),
|
||||
additional: [
|
||||
await this.ticketCountPerStateMetric(ticketState),
|
||||
...(ticketState === TicketValidationState.USER_INTERACTION_REQUIRED
|
||||
? [await this.ticketApprovalRequiredCountMetric(ticketState)]
|
||||
: []),
|
||||
await this.tourCountPerTicketStateMetric(ticketState),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async fastMovingTicketPercentMetric(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<MetricObjectType> {
|
||||
const ticketsGotState24HoursAgo = await this.prisma.ticket.findMany({
|
||||
where: {
|
||||
state: {
|
||||
some: {
|
||||
state: ticketState,
|
||||
createdAt: {
|
||||
gte: subHours(new Date(), 24),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ticketsMoved = ticketsGotState24HoursAgo.filter(
|
||||
({ currentState }) => currentState !== ticketState,
|
||||
);
|
||||
|
||||
return {
|
||||
label: 'Neue Tickets bearbeitet innerhalb von 24 Stunden',
|
||||
value:
|
||||
ticketsMoved.length === 0
|
||||
? 0
|
||||
: Math.floor(
|
||||
(100 / ticketsGotState24HoursAgo.length) * ticketsMoved.length,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async averageTimePerStateMetric(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<MetricObjectType> {
|
||||
const movedTickets = await this.prisma.ticket.findMany({
|
||||
where: {
|
||||
currentState: {
|
||||
not: ticketState,
|
||||
},
|
||||
state: {
|
||||
some: {
|
||||
state: ticketState,
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
gte: subMonths(new Date(), 3),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
state: true,
|
||||
},
|
||||
});
|
||||
const timePerTickets = movedTickets
|
||||
.map(({ state }) => {
|
||||
const statesSorted = [...state].sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
const currentStateIdx = statesSorted.findIndex(
|
||||
(s) => s.state === ticketState,
|
||||
);
|
||||
if (currentStateIdx === -1) return;
|
||||
// if (statesSorted.length - 1 === currentStateIdx) return;
|
||||
return differenceInHours(
|
||||
new Date(statesSorted.at(currentStateIdx + 1).createdAt),
|
||||
new Date(statesSorted.at(currentStateIdx).createdAt),
|
||||
);
|
||||
})
|
||||
.filter((v) => !!v);
|
||||
if (timePerTickets.length === 0) {
|
||||
return { label: '⌀ Verweildauer (in h)', value: 0 };
|
||||
}
|
||||
return {
|
||||
label: '⌀ Verweildauer (in h)',
|
||||
value:
|
||||
timePerTickets.reduce((sum, cur) => (sum += cur), 0) /
|
||||
timePerTickets.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async ticketCountPerStateMetric(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<MetricObjectType> {
|
||||
const count = await this.prisma.ticket.count({
|
||||
where: { ...ticketWithCurrentStateAndTours(ticketState) },
|
||||
});
|
||||
return { label: 'Tickets', value: count };
|
||||
}
|
||||
|
||||
private async ticketApprovalRequiredCountMetric(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<MetricObjectType> {
|
||||
const count = await this.prisma.ticket.count({
|
||||
where: {
|
||||
...ticketWithCurrentStateAndTours(ticketState),
|
||||
approvalState: ApprovalState.REQUIRED,
|
||||
},
|
||||
});
|
||||
return { label: 'Genehmigungspflicht', value: count };
|
||||
}
|
||||
|
||||
private async tourCountPerTicketStateMetric(
|
||||
ticketState: TicketValidationState,
|
||||
): Promise<MetricObjectType> {
|
||||
const count = await this.prisma.tour.count({
|
||||
where: {
|
||||
Ticket: {
|
||||
...ticketWithCurrentStateAndTours(ticketState),
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
label: 'Touren',
|
||||
value: count,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('Metric')
|
||||
export class MetricObjectType {
|
||||
@Field()
|
||||
label: string;
|
||||
@Field(() => Float)
|
||||
value: number;
|
||||
}
|
||||
|
||||
@ObjectType('KpiInfo')
|
||||
export class KpiInfoObjectType {
|
||||
@Field(() => MetricObjectType)
|
||||
main: MetricObjectType;
|
||||
@Field(() => MetricObjectType)
|
||||
secondary: MetricObjectType;
|
||||
@Field(() => [MetricObjectType])
|
||||
additional: MetricObjectType[];
|
||||
}
|
||||
+32
-8
@@ -5,6 +5,8 @@ import {
|
||||
TicketValidationState,
|
||||
Tour,
|
||||
} from '@prisma/client';
|
||||
import { Interval } from 'date-fns';
|
||||
import { DocumentInfoArgs } from './ticket-arg.types';
|
||||
import {
|
||||
ValidationResult,
|
||||
validatePatient,
|
||||
@@ -12,8 +14,6 @@ import {
|
||||
validateTour,
|
||||
} from './ticket-validation.functions';
|
||||
import { TicketRepository } from './ticket.repository';
|
||||
import { DocumentInfoArgs } from './ticket-arg.types';
|
||||
import { Interval } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class TicketLifecycleService {
|
||||
@@ -118,12 +118,28 @@ export class TicketLifecycleService {
|
||||
: ApprovalState.REQUIRED,
|
||||
});
|
||||
} else {
|
||||
await this.repository.updateTicket(ticket.id, {
|
||||
errors,
|
||||
notes,
|
||||
state: TicketValidationState.USER_INTERACTION_REQUIRED,
|
||||
approvalState: ApprovalState.FREE,
|
||||
});
|
||||
// NOTE: Condition required to get these tickets out of USER_INTERACTION_REQUIRED
|
||||
if (
|
||||
ticket.tours.every(
|
||||
({ ordinanceType }) =>
|
||||
ordinanceType.indexOf('BTW') > -1 ||
|
||||
ordinanceType.indexOf('TSW') > -1,
|
||||
)
|
||||
) {
|
||||
await this.repository.updateTicket(ticket.id, {
|
||||
errors,
|
||||
notes,
|
||||
state: TicketValidationState.TD_UNCERTAIN,
|
||||
approvalState: ApprovalState.FREE,
|
||||
});
|
||||
} 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);
|
||||
@@ -210,6 +226,14 @@ export class TicketLifecycleService {
|
||||
}
|
||||
|
||||
private async checkTourApprovalRequirement(tour: Tour): Promise<boolean> {
|
||||
// NOTE: Requirement by Avicenna
|
||||
if (
|
||||
tour.ordinanceType.indexOf('BTW') > -1 ||
|
||||
tour.ordinanceType.indexOf('TSW') > -1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'Einweisung',
|
||||
|
||||
+9
-9
@@ -27,15 +27,15 @@ export function validatePatient(patient: Patient): ValidationResult {
|
||||
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');
|
||||
}
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import * as opentelemetry from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
ATTR_SERVICE_NAME,
|
||||
ATTR_SERVICE_VERSION,
|
||||
} from '@opentelemetry/semantic-conventions';
|
||||
|
||||
// Configure the SDK to export telemetry data to the console
|
||||
// Enable all auto-instrumentations from the meta package
|
||||
const exporterOptions = {
|
||||
url: process.env.TRACE_EXPORTER_URL ?? 'http://localhost:4318/v1/traces',
|
||||
};
|
||||
|
||||
const traceExporter = new OTLPTraceExporter(exporterOptions);
|
||||
const sdk = new opentelemetry.NodeSDK({
|
||||
traceExporter,
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
resource: resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: process.env.OTLP_SERVICE_NAME ?? 'avicenna.data-connector',
|
||||
// [ATTR_SERVICE_NAMESPACE]: 'avicenna',
|
||||
[ATTR_SERVICE_VERSION]: '1.0',
|
||||
}),
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
// gracefully shut down the SDK on process exit
|
||||
process.on('SIGTERM', () => {
|
||||
sdk
|
||||
.shutdown()
|
||||
.then(() => console.log('Tracing terminated'))
|
||||
.catch((error) => console.log('Error terminating tracing', error))
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
export default sdk;
|
||||
Reference in New Issue
Block a user