feat: implement new tour calculation rules

This commit is contained in:
Marcel Arndt 2025-08-20 10:43:29 +02:00
parent 41472ba17b
commit aa29d58243
12 changed files with 136 additions and 34 deletions

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "CostTypeVariant" ADD VALUE 'TRAGEHILFE';

View File

@ -531,6 +531,7 @@ enum CostTypeVariant {
GRUNDPREIS GRUNDPREIS
GRUNDPREIS_ZEIT GRUNDPREIS_ZEIT
PRIVATVERSICHERUNG PRIVATVERSICHERUNG
TRAGEHILFE
} }
model AvicennaTenant { model AvicennaTenant {

View File

@ -289,6 +289,11 @@ export const KTW_DAK_TARIFFS = [
costAmount: 354, costAmount: 354,
kmInclusive: 16, kmInclusive: 16,
}, },
{
description: 'Tragehilfe',
variant: CostTypeVariant.TRAGEHILFE,
costAmount: 5360,
},
{ {
description: 'Nachtzuschlag 19:00-19:59', description: 'Nachtzuschlag 19:00-19:59',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.NACHTZUSCHLAG,
@ -375,6 +380,11 @@ export const KTW_DAK_TARIFFS = [
costAmount: 369, costAmount: 369,
kmInclusive: 16, kmInclusive: 16,
}, },
{
description: 'Tragehilfe',
variant: CostTypeVariant.TRAGEHILFE,
costAmount: 5596,
},
{ {
description: 'Nachtzuschlag 19:00-19:59', description: 'Nachtzuschlag 19:00-19:59',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.NACHTZUSCHLAG,
@ -461,6 +471,11 @@ export const KTW_DAK_TARIFFS = [
costAmount: 369, costAmount: 369,
kmInclusive: 16, kmInclusive: 16,
}, },
{
description: 'Tragehilfe',
variant: CostTypeVariant.TRAGEHILFE,
costAmount: 5596,
},
{ {
description: 'Nachtzuschlag 19:00-19:59', description: 'Nachtzuschlag 19:00-19:59',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.NACHTZUSCHLAG,

View File

@ -366,6 +366,11 @@ export const KTW_VDEK_TARIFFS = [
costAmount: 402, costAmount: 402,
kmInclusive: 16, kmInclusive: 16,
}, },
{
description: 'Tragehilfe',
variant: CostTypeVariant.TRAGEHILFE,
costAmount: 6271,
},
{ {
description: 'Nachtzuschlag 19:00-19:59', description: 'Nachtzuschlag 19:00-19:59',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.NACHTZUSCHLAG,

View File

@ -145,18 +145,19 @@ export const TSW_AOK_TARIFFS = [
costTypes: [ costTypes: [
{ {
description: 'Grundpauschale je Fahrt', description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.GRUNDPREIS, variant: CostTypeVariant.GRUNDPREIS_ZEIT,
costAmount: 6000, costAmount: 6000,
timeRange: '00:00-14:29',
}, },
{ {
description: 'ab dem 21. km', description: 'Grundpauschale je Fahrt',
variant: CostTypeVariant.KILOMETERZUSCHLAG, variant: CostTypeVariant.GRUNDPREIS_ZEIT,
costAmount: 200, costAmount: 6000,
kmInclusive: 20, timeRange: '18:01-23:59',
}, },
{ {
description: 'Nachmittagszuschlag 14:30-18:00', description: 'Nachmittagspauschale 14:30-18:00',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.GRUNDPREIS_ZEIT,
costAmount: 7000, costAmount: 7000,
timeRange: '14:30-18:00', timeRange: '14:30-18:00',
}, },
@ -166,6 +167,12 @@ export const TSW_AOK_TARIFFS = [
costAmount: 5000, costAmount: 5000,
timeRange: '18:00-07:30', timeRange: '18:00-07:30',
}, },
{
description: 'ab dem 21. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 200,
kmInclusive: 20,
},
{ {
description: 'Wochenendzuschlag', description: 'Wochenendzuschlag',
variant: CostTypeVariant.WOCHENENDZUSCHLAG, variant: CostTypeVariant.WOCHENENDZUSCHLAG,

View File

@ -38,7 +38,7 @@ export const TSW_DAK_VDEK_TARIFFS = [
{ {
description: 'ab dem 22. km', description: 'ab dem 22. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG, variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 159, costAmount: 166,
kmInclusive: 21, kmInclusive: 21,
}, },
{ {
@ -90,13 +90,13 @@ export const TSW_DAK_VDEK_TARIFFS = [
{ {
description: 'Abendzuschlag 19:00-23:00', description: 'Abendzuschlag 19:00-23:00',
variant: CostTypeVariant.NACHTZUSCHLAG, variant: CostTypeVariant.NACHTZUSCHLAG,
costAmount: 407, costAmount: 750,
timeRange: '19:00-23:00', timeRange: '19:00-23:00',
}, },
{ {
description: 'ab dem 22. km', description: 'ab dem 22. km',
variant: CostTypeVariant.KILOMETERZUSCHLAG, variant: CostTypeVariant.KILOMETERZUSCHLAG,
costAmount: 159, costAmount: 180,
kmInclusive: 21, kmInclusive: 21,
}, },
], ],

View File

@ -39,7 +39,8 @@ export const CostTypeVariant = {
TSWDIFFERENZIERUNG: "TSWDIFFERENZIERUNG", TSWDIFFERENZIERUNG: "TSWDIFFERENZIERUNG",
GRUNDPREIS: "GRUNDPREIS", GRUNDPREIS: "GRUNDPREIS",
GRUNDPREIS_ZEIT: "GRUNDPREIS_ZEIT", GRUNDPREIS_ZEIT: "GRUNDPREIS_ZEIT",
PRIVATVERSICHERUNG: "PRIVATVERSICHERUNG" PRIVATVERSICHERUNG: "PRIVATVERSICHERUNG",
TRAGEHILFE: "TRAGEHILFE"
} as const; } as const;
export type CostTypeVariant = (typeof CostTypeVariant)[keyof typeof CostTypeVariant]; export type CostTypeVariant = (typeof CostTypeVariant)[keyof typeof CostTypeVariant];
export type Anomaly = { export type Anomaly = {

View File

@ -10,6 +10,7 @@ import {
import { TicketValidationState } from '@prisma/client'; import { TicketValidationState } from '@prisma/client';
import { TourObjectType } from '../app-accounting/tour.object-type'; import { TourObjectType } from '../app-accounting/tour.object-type';
import { DocumentInfoArgs, TicketsArgs } from './ticket-arg.types'; import { DocumentInfoArgs, TicketsArgs } from './ticket-arg.types';
import { TicketSystemService } from './ticket-system.service';
import { import {
PaginatedTicketObjectType, PaginatedTicketObjectType,
TicketObjectType, TicketObjectType,
@ -17,7 +18,6 @@ import {
TicketStateMetaObjectType, TicketStateMetaObjectType,
TicketsUpdatedObjectType, TicketsUpdatedObjectType,
} from './ticket.object-type'; } from './ticket.object-type';
import { TicketSystemService } from './ticket-system.service';
@Resolver(() => TicketObjectType) @Resolver(() => TicketObjectType)
export class TicketsResolver { export class TicketsResolver {
@ -43,6 +43,7 @@ export class TicketsResolver {
return this.ticketsService.searchTickets(query); return this.ticketsService.searchTickets(query);
} }
// TODO: obsolete
@Query(() => TicketStateMetaObjectType) @Query(() => TicketStateMetaObjectType)
ticketStateMeta( ticketStateMeta(
@Args('state', { type: () => TicketValidationState }) @Args('state', { type: () => TicketValidationState })
@ -51,6 +52,7 @@ export class TicketsResolver {
return this.ticketsService.getTicketStateMeta(ticketState); return this.ticketsService.getTicketStateMeta(ticketState);
} }
// TODO: obsolete
@Query(() => TicketStateKeyFiguresObjectType) @Query(() => TicketStateKeyFiguresObjectType)
ticketStateKeyFigures( ticketStateKeyFigures(
@Args('state', { type: () => TicketValidationState }) @Args('state', { type: () => TicketValidationState })

View File

@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Prisma, Tour } from '@prisma/client'; import { Prisma, Tour } from '@prisma/client';
import { endOfDay, Interval, startOfDay } from 'date-fns'; import { endOfDay, Interval, startOfDay } from 'date-fns';
import { Kysely } from 'kysely';
import { KYSELY_CLIENT_PROVIDER_TOKEN } from 'src/core/database/database.module';
import { PrismaService } from 'src/core/database/prisma.service'; import { PrismaService } from 'src/core/database/prisma.service';
import { DB } from 'src/core/database/types';
import { AccountingState } from 'src/modules/app-accounting/accounting-state'; import { AccountingState } from 'src/modules/app-accounting/accounting-state';
import { import {
createPrismaQueryFromAccountingFilters, createPrismaQueryFromAccountingFilters,
@ -72,7 +75,10 @@ function onlyRealTours(): Prisma.TourWhereInput[] {
@Injectable() @Injectable()
export class TourRepository { export class TourRepository {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
@Inject(KYSELY_CLIENT_PROVIDER_TOKEN) private readonly db: Kysely<DB>,
) {}
async findByOperationId(operationId: string): Promise<Tour> { async findByOperationId(operationId: string): Promise<Tour> {
return await this.prisma.tour.findFirst({ return await this.prisma.tour.findFirst({
@ -170,6 +176,40 @@ export class TourRepository {
}); });
} }
async findTragehilfeTour(tour: Tour): Promise<Tour | null> {
const {
patientId,
patientName,
patientSurname,
patientCity,
patientStreet,
patientZip,
} = tour;
return await this.prisma.tour.findFirst({
where: {
patientId,
patientName,
patientSurname,
patientStreet,
patientCity,
patientZip,
ordinanceType: 'Tragehilfe',
AND: [
{
startDate: {
gte: startOfDay(new Date(tour.startDate)),
},
},
{
startDate: {
lt: endOfDay(new Date(tour.startDate)),
},
},
],
},
});
}
// -------------------- Ticket System ------------------- // // -------------------- Ticket System ------------------- //
async findFirstRecurring(tour: Tour): Promise<Tour | null> { async findFirstRecurring(tour: Tour): Promise<Tour | null> {
const institutionStreet = const institutionStreet =

View File

@ -48,6 +48,10 @@ export class TourService {
return this.repository.findOnSameDayWithOppositeDirection(tour); return this.repository.findOnSameDayWithOppositeDirection(tour);
} }
async findTragehilfeTour(tour: Tour): Promise<Tour | null> {
return this.repository.findTragehilfeTour(tour)
}
async findToursWithTicket(interval: Interval): Promise<Tour[]> { async findToursWithTicket(interval: Interval): Promise<Tour[]> {
return this.repository.findWithTicket(interval); return this.repository.findWithTicket(interval);
} }

View File

@ -10,6 +10,7 @@ import { Interval, addDays, isSaturday, isWithinInterval } from 'date-fns';
import { generateIntervalForTimeRange } from 'src/utils'; import { generateIntervalForTimeRange } from 'src/utils';
import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service'; import { TariffService } from '../feat-business-objects/entities/tariff/tariff.service';
import { TourService } from '../feat-business-objects/entities/tour/tour.service';
import { BILLING_INFORMATION_BY_ORDINANCE_TYPE } from './billing-information-by-ordinance-type'; import { BILLING_INFORMATION_BY_ORDINANCE_TYPE } from './billing-information-by-ordinance-type';
import { import {
calculateDesinfektionszuschlag, calculateDesinfektionszuschlag,
@ -38,6 +39,7 @@ export class CalculationService {
constructor( constructor(
private readonly personnelCostCalculationService: PersonnelCostCalculationService, private readonly personnelCostCalculationService: PersonnelCostCalculationService,
private readonly tariffService: TariffService, private readonly tariffService: TariffService,
private readonly tourService: TourService,
) {} ) {}
async calculateTourRevenue(tour: Tour): Promise<number> { async calculateTourRevenue(tour: Tour): Promise<number> {
@ -56,12 +58,11 @@ export class CalculationService {
); );
return 0; return 0;
} }
const revenue = const servicePositions = await this.calculateProvidedServicePositions(
this.calculateProvidedServicePositions(tour, tariff).reduce( tour,
(sum, [, value]) => (sum += value), tariff,
0, );
) / 100; return servicePositions.reduce((sum, [, value]) => (sum += value), 0) / 100;
return revenue;
} }
async listProvidedServicePositions(tour: Tour): Promise<{ async listProvidedServicePositions(tour: Tour): Promise<{
@ -85,7 +86,10 @@ export class CalculationService {
} }
return { return {
tariff, tariff,
servicePositions: this.calculateProvidedServicePositions(tour, tariff), servicePositions: await this.calculateProvidedServicePositions(
tour,
tariff,
),
}; };
} }
@ -201,18 +205,19 @@ export class CalculationService {
return billingInformation[1]; return billingInformation[1];
} }
private calculateProvidedServicePositions( private async calculateProvidedServicePositions(
tour: Tour, tour: Tour,
tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup }, tariff: Tariff & { costTypes: CostType[]; tariffGroup: TariffGroup },
): [CostType, number][] { ): Promise<[CostType, number][]> {
const highestZuschlag = this.getHighestZuschlag(tour, tariff); const highestZuschlag = this.getHighestZuschlag(tour, tariff);
// if TariffGroup is VDEK KTW, sum up costTypes // if TariffGroup is VDEK KTW, sum up costTypes
const sumZuschlaege = const sumZuschlaege =
tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 || tariff.tariffGroup?.description?.indexOf('VDEK KTW') > -1 ||
tariff.tariffGroup?.description?.indexOf('DAK KTW') > -1 ||
tariff.tariffGroup?.description?.indexOf('Privat') > -1; tariff.tariffGroup?.description?.indexOf('Privat') > -1;
return tariff.costTypes return Promise.all(
.map((costType) => { tariff.costTypes.map(async (costType) => {
switch (costType.variant) { switch (costType.variant) {
case CostTypeVariant.GRUNDPREIS: case CostTypeVariant.GRUNDPREIS:
return [costType, costType.costAmount]; return [costType, costType.costAmount];
@ -224,6 +229,13 @@ export class CalculationService {
return [costType, calculateDesinfektionszuschlag(tour, costType)]; return [costType, calculateDesinfektionszuschlag(tour, costType)];
case CostTypeVariant.KILOMETERZUSCHLAG: case CostTypeVariant.KILOMETERZUSCHLAG:
return [costType, calculateKilometerzuschlag(tour, costType)]; return [costType, calculateKilometerzuschlag(tour, costType)];
// Currently the only async task in calculation
case CostTypeVariant.TRAGEHILFE:
const hasTragehilfe = await this.hasTragehilfe(tour);
if (hasTragehilfe) return [costType, costType.costAmount];
return [costType, 0];
case CostTypeVariant.FAHRZEUGZUSCHLAG: case CostTypeVariant.FAHRZEUGZUSCHLAG:
return [costType, calculateFahrzeugzuschlag(tour, costType)]; return [costType, calculateFahrzeugzuschlag(tour, costType)];
case CostTypeVariant.GRUNDPREIS_ZEIT: case CostTypeVariant.GRUNDPREIS_ZEIT:
@ -253,8 +265,14 @@ export class CalculationService {
} }
return [costType, 0]; return [costType, 0];
} }
}) }),
.filter(([, value]) => value !== 0) as [CostType, number][]; ).then(
(servicePositions) =>
servicePositions.filter(([, value]) => value !== 0) as [
CostType,
number,
][],
);
} }
private getHighestZuschlag( private getHighestZuschlag(
@ -330,7 +348,8 @@ export class CalculationService {
); );
} }
getHealthInsurancesWithNoTenant(): string[] { private async hasTragehilfe(tour: Tour): Promise<boolean> {
return [...new Set(this.healthInsurancesWithNoTenant)]; const tragehilfeTour = await this.tourService.findTragehilfeTour(tour);
return !!tragehilfeTour;
} }
} }

View File

@ -5,9 +5,15 @@ import { generateIntervalForTimeRange } from 'src/utils';
import { INFECTION_TYPES } from './infection-types'; import { INFECTION_TYPES } from './infection-types';
import { PUBLIC_HOLIDAYS_HH } from './public-holidays'; import { PUBLIC_HOLIDAYS_HH } from './public-holidays';
export function calculatePrivatVersicherung(tour: Tour, costType: CostType): number { export function calculatePrivatVersicherung(
if(tour.category?.indexOf('Privatfahrt Rechnung') === -1 && tour.category?.indexOf('Barzahlung') === -1) { tour: Tour,
if(costType.description === 'Hygienemaßnahmen' && tour.hasInfection) { costType: CostType,
): number {
if (
tour.category?.indexOf('Privatfahrt Rechnung') === -1 &&
tour.category?.indexOf('Barzahlung') === -1
) {
if (costType.description === 'Hygienemaßnahmen' && tour.hasInfection) {
return 0; return 0;
} }
return costType.costAmount; return costType.costAmount;
@ -38,13 +44,13 @@ export function calculateTSW(tour: Tour, costType: CostType): number {
} else if ( } else if (
(costType.description.includes('TS 1') || (costType.description.includes('TS 1') ||
costType.description.includes('TSW 1')) && costType.description.includes('TSW 1')) &&
tour.transportType === 'tsw' (tour.transportType === 'tsw' || tour.transportType === 'TS1')
) { ) {
return costType.costAmount; return costType.costAmount;
} else if ( } else if (
(costType.description.includes('TS 2') || (costType.description.includes('TS 2') ||
costType.description.includes('TSW 2')) && costType.description.includes('TSW 2')) &&
tour.transportType === 'TS+T' (tour.transportType === 'TS+T' || tour.transportType === 'TS2')
) { ) {
return costType.costAmount; return costType.costAmount;
} }