feat: implement incremental tour update

This commit is contained in:
Marcel Arndt 2025-08-20 10:45:01 +02:00
parent aa29d58243
commit 319e7f3614
15 changed files with 5462 additions and 294 deletions

File diff suppressed because it is too large Load Diff

View File

@ -63,6 +63,15 @@ export class AppController {
.then(() => this.anomaliesService.validateAnomalies()); .then(() => this.anomaliesService.validateAnomalies());
} }
@Get('updated-tours')
async updatedTours(@Query('from') from: string, @Query('to') to: string) {
const interval = {
start: new Date(from),
end: new Date(to),
};
return await this.importer.runTourUpdateCheck(interval);
}
@Get('schichtplanung') @Get('schichtplanung')
async syncSchichtplanung(@Query('day') day: string) { async syncSchichtplanung(@Query('day') day: string) {
return await this.importer.runSchichtplanungSync(new Date(day)); return await this.importer.runSchichtplanungSync(new Date(day));
@ -120,9 +129,7 @@ export class AppController {
], ],
}, },
{ {
check: { check: 2,
not: 3,
},
}, },
{ {
deletedAt: null, deletedAt: null,

View File

@ -1,4 +1,6 @@
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { HttpModule } from '@nestjs/axios';
import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { GraphQLModule } from '@nestjs/graphql'; import { GraphQLModule } from '@nestjs/graphql';
@ -13,14 +15,15 @@ import { DatabaseModule } from './core/database/database.module';
import { PrismaModule } from './core/database/prisma.module'; import { PrismaModule } from './core/database/prisma.module';
import { ApplicationAccountingModule } from './modules/app-accounting/app-accounting.module'; import { ApplicationAccountingModule } from './modules/app-accounting/app-accounting.module';
import { ApplicationControlCenterModule } from './modules/app-control-center/app-control-center.module'; import { ApplicationControlCenterModule } from './modules/app-control-center/app-control-center.module';
import { AppHomeModule } from './modules/app-home/app-home.module';
import { ManagerModule } 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 { ApplicationTicketSystemModule } from './modules/app-ticket-system/app-ticket-system.module';
import { AnomalyDetectionModule } from './modules/feat-anomalies/anomaly-detection.module'; import { AnomalyDetectionModule } from './modules/feat-anomalies/anomaly-detection.module';
import { FeatCalculationModule } from './modules/feat-calculation/feat-calculation.module';
import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module'; import { FeatureEtlModule } from './modules/feat-etl/feat-etl.module';
import { FeatReportsModule } from './modules/feat-reports/feat-reports.module'; import { FeatReportsModule } from './modules/feat-reports/feat-reports.module';
import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module'; import { FeatTicketSystemModule } from './modules/feat-tickets/ticket-system.module';
import { FeatCalculationModule } from './modules/feat-calculation/feat-calculation.module'; import { FeatBusinessObjectsModule } from './modules/feat-business-objects/feat-business-objects.module';
import { AppHomeModule } from './modules/app-home/app-home.module';
@Module({ @Module({
imports: [ imports: [
@ -48,7 +51,7 @@ import { AppHomeModule } from './modules/app-home/app-home.module';
// isGlobal: true, // isGlobal: true,
dialect: new PostgresDialect({ dialect: new PostgresDialect({
pool: new Pool({ pool: new Pool({
host: 'db', host: 'localhost',
port: 5432, port: 5432,
database: 'avicenna', database: 'avicenna',
user: 'johndoe', user: 'johndoe',

View File

@ -26,7 +26,7 @@ export class AppService {
private readonly schedulerRegistry: SchedulerRegistry, private readonly schedulerRegistry: SchedulerRegistry,
) {} ) {}
@Cron('0 */5 2-20 * * *') // @Cron('0 */5 2-20 * * *')
async fetchDataContinously() { async fetchDataContinously() {
const now = new Date(); const now = new Date();
const fetchInterval = { const fetchInterval = {

View File

@ -6,6 +6,7 @@ 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 { 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 { chunk } from 'src/utils';
import { import {
createPrismaQueryFromAccountingFilters, createPrismaQueryFromAccountingFilters,
TourAccountingFilterDto, TourAccountingFilterDto,
@ -127,6 +128,37 @@ export class TourRepository {
}); });
} }
async findUpdatedAtBetween(
firstId: string,
lastId: string,
): Promise<{ id: string; updatedAt: Date }[]> {
return await this.db
.with('boundary_dates', (db) =>
db
.selectFrom('Tour')
.select('startDate')
.where('id', 'in', [firstId, lastId]),
)
.selectFrom('Tour')
.select(['Tour.id', 'operationId', 'updatedAt'])
.where((eb) =>
eb.and([
eb('Tour.startDate', '>=', (eb) =>
eb
.selectFrom('boundary_dates')
.select(eb.fn.min('startDate').as('min_date')),
),
eb('Tour.startDate', '<=', (eb) =>
eb
.selectFrom('boundary_dates')
.select(eb.fn.max('startDate').as('max_date')),
),
]),
)
.orderBy('Tour.updatedAt', 'asc')
.execute();
}
public findInIntervalWithEmployee( public findInIntervalWithEmployee(
{ start, end }: Interval, { start, end }: Interval,
employeeId: string, employeeId: string,
@ -210,6 +242,44 @@ export class TourRepository {
}); });
} }
async createTour(tourInput: Prisma.TourCreateInput): Promise<Tour> {
return await this.prisma.tour.create({
data: {
...tourInput,
},
});
}
async updateTour(
id: string,
tourInput: Prisma.TourUpdateInput,
): Promise<Tour> {
return await this.prisma.tour.update({
where: { id },
data: {
...tourInput,
},
});
}
async deleteTours(ids: string[]): Promise<Prisma.BatchPayload[]> {
const chunks = chunk(ids, 2000);
return await Promise.all(
chunks.map((chunk) =>
this.prisma.tour.updateMany({
where: {
id: {
in: chunk,
},
},
data: {
deletedAt: new Date(),
},
}),
),
);
}
// -------------------- Ticket System ------------------- // // -------------------- Ticket System ------------------- //
async findFirstRecurring(tour: Tour): Promise<Tour | null> { async findFirstRecurring(tour: Tour): Promise<Tour | null> {
const institutionStreet = const institutionStreet =

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Tour } from '@prisma/client'; import { Prisma, Tour } from '@prisma/client';
import { Interval } from 'date-fns'; import { Interval } from 'date-fns';
import { TourRepository } from './tour.repository';
import { AccountingState } from 'src/modules/app-accounting/accounting-state'; import { AccountingState } from 'src/modules/app-accounting/accounting-state';
import { TourRepository } from './tour.repository';
import { TourAccountingFilterDto } from './tours.filter'; import { TourAccountingFilterDto } from './tours.filter';
@Injectable() @Injectable()
@ -16,6 +16,13 @@ export class TourService {
return this.repository.findInInterval(interval, ordinanceTypes); return this.repository.findInInterval(interval, ordinanceTypes);
} }
async findUpdatedAtBetween(
firstId: string,
lastId: string,
): Promise<{ id: string; updatedAt: Date }[]> {
return this.repository.findUpdatedAtBetween(firstId, lastId);
}
public findInIntervalWithEmployee( public findInIntervalWithEmployee(
interval: Interval, interval: Interval,
employeeId: string, employeeId: string,
@ -60,6 +67,22 @@ export class TourService {
return this.repository.removeTourFromTicket(tourId); return this.repository.removeTourFromTicket(tourId);
} }
async createTour(tourInput: Prisma.TourCreateInput): Promise<Tour> {
return this.repository.createTour(tourInput);
}
async updateTour(
id: string,
tourInput: Prisma.TourUpdateInput,
): Promise<Tour> {
return this.repository.updateTour(id, tourInput);
}
async deleteTours(ids: string[]): Promise<Prisma.BatchPayload[]> {
return this.repository.deleteTours(ids);
}
async findByAccountingState( async findByAccountingState(
state: AccountingState, state: AccountingState,
filter: TourAccountingFilterDto, filter: TourAccountingFilterDto,

View File

@ -0,0 +1 @@
export const TOURS_QUEUE = 'tours';

View File

@ -24,6 +24,7 @@ import { EmployeeDispoLiveRaw, PatientRaw, TourRaw } from './source.interface';
import { SourceRepository } from './source.repository'; import { SourceRepository } from './source.repository';
import { chunk } from 'src/utils'; import { chunk } from 'src/utils';
import { ConfigService } from 'src/core/config/config.service'; import { ConfigService } from 'src/core/config/config.service';
import { TourExtractor } from './extractors/tour.extractor';
@Injectable() @Injectable()
export class DispoLiveExtractorsService { export class DispoLiveExtractorsService {
@ -35,6 +36,10 @@ export class DispoLiveExtractorsService {
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
extractTour(id: string, jobId: string) {
return this.fetchTour(id).pipe(map((tour) => ({ ...tour, jobId })));
}
extractTours( extractTours(
interval: Interval, interval: Interval,
jobId: string, jobId: string,
@ -54,6 +59,67 @@ export class DispoLiveExtractorsService {
); );
} }
private fetchTour(id: string): Observable<TourRaw> {
const fahrberichteEndpoint = 'data/fahrberichte';
const carsEndpoint = 'data/stammdatenFahrzeuge';
const fetchCars = (
authCookies: string,
): Observable<{ id: string; name: string }[]> =>
this.http
.post<{ data: { _id: string; name: string }[] }>(
`${this.config.dispoliveBaseUrl()}/${carsEndpoint}`,
{},
{
headers: {
'Content-Type': 'application/json',
Cookie: authCookies,
},
},
)
.pipe(
map((response) =>
response.data.data.map(({ _id, name }) => ({ id: _id, name })),
),
);
const fetchTour = (id: string, authCookies: string) =>
this.http
.post<{ data: TourRaw[] }>(
`${this.config.dispoliveBaseUrl()}/${fahrberichteEndpoint}`,
{
filter: { _id: id },
},
{
headers: {
'Content-Type': 'application/json',
Cookie: authCookies,
},
},
)
.pipe(
map((response) => TourExtractor.extract(response.data.data?.at(0))),
);
return this.dispoliveLogin(
this.config.dispoliveUsername(),
this.config.dispolivePassword(),
).pipe(
switchMap((authCookies) =>
forkJoin([of(authCookies), fetchCars(authCookies)]),
),
switchMap(([authCookies, cars]) =>
fetchTour(id, authCookies).pipe(
map(({ carNo, ...tour }) => ({
...tour,
carNo,
carName: cars.find(({ id }) => id === carNo)?.name ?? tour.carName,
})),
),
),
);
}
private fetchTours(intervals: Interval[]): Observable<TourRaw[]> { private fetchTours(intervals: Interval[]): Observable<TourRaw[]> {
const archiveEndpoint = 'custom/getArchive/getArchive'; const archiveEndpoint = 'custom/getArchive/getArchive';
const carsEndpoint = 'data/stammdatenFahrzeuge'; const carsEndpoint = 'data/stammdatenFahrzeuge';
@ -95,156 +161,7 @@ export class DispoLiveExtractorsService {
) )
.pipe( .pipe(
map((response) => map((response) =>
response.data.map((tour) => { response.data.map((tour) => TourExtractor.extract(tour)),
const {
_id,
check,
createTime,
lastUpdate,
empfangenVonId,
endgen,
serienId,
serienKategorName,
serienKategorie,
zadStatus,
abrDate,
abrNum,
abrTime,
ausrueckZeit,
startTime,
startDate,
date,
date_iso,
dauergenehmigung,
direction,
distanceValue,
besetztKm,
gefahren,
gesamtPreis,
infektion,
itDesc,
itKrz,
itName,
kkId,
krankenkasse,
patId,
patName,
patSurname,
patStreet,
patZip,
patCity,
startInstitution,
startStreet,
startZip,
startCity,
targetInstitution,
targetStreet,
targetCity,
targetZip,
transportKrz,
transportName,
transportart,
verordnungsId,
verordnungsName,
type,
startBegin,
startEnd,
frei,
ziel,
zielBegin,
endTime,
endDate,
einsatzNummer,
carName,
carNo,
beifahrer,
beifahrerName,
fahrer,
fahrerName,
color,
} = tour;
return {
id: _id ? `${_id}` : undefined,
check: check ? `${check}` : undefined,
createTime: createTime ? `${createTime}` : undefined,
lastUpdate: lastUpdate ? `${lastUpdate}` : undefined,
empfangenVonId: empfangenVonId
? `${empfangenVonId}`
: undefined,
endgen: endgen ? `${endgen}` : undefined,
serienId: serienId ? `${serienId}` : undefined,
serienKategorName: serienKategorName
? `${serienKategorName}`
: undefined,
serienKategorie: serienKategorie
? `${serienKategorie}`
: undefined,
zadStatus: zadStatus ? `${zadStatus}` : undefined,
abrDate: abrDate ? `${abrDate}` : undefined,
abrNum: abrNum ? `${abrNum}` : undefined,
abrTime: abrTime ? `${abrTime}` : undefined,
ausrueckZeit: ausrueckZeit ? `${ausrueckZeit}` : undefined,
startTime: startTime ? `${startTime}` : undefined,
startDate: startDate ? `${startDate}` : undefined,
date: date ? `${date}` : undefined,
date_iso: date_iso ? `${date_iso}` : undefined,
dauergenehmigung: dauergenehmigung
? `${dauergenehmigung}`
: undefined,
direction: direction ? `${direction}` : undefined,
distanceValue: distanceValue ? `${distanceValue}` : undefined,
besetztKm: besetztKm ? `${besetztKm}` : undefined,
gefahren: gefahren ? `${gefahren}` : undefined,
gesamtPreis: gesamtPreis ? `${gesamtPreis}` : undefined,
infektion: infektion ? `${infektion}` : undefined,
itDesc: itDesc ? `${itDesc}` : undefined,
itKrz: itKrz ? `${itKrz}` : undefined,
itName: itName ? `${itName}` : undefined,
kkId: kkId ? `${kkId}` : undefined,
krankenkasse: krankenkasse ? `${krankenkasse}` : undefined,
patId: patId ? `${patId}` : undefined,
patName: patName ? `${patName}` : undefined,
patSurname: patSurname ? `${patSurname}` : undefined,
patStreet: patStreet ? `${patStreet}` : undefined,
patZip: patZip ? `${patZip}` : undefined,
patCity: patCity ? `${patCity}` : undefined,
startInstitution: startInstitution
? `${startInstitution}`
: undefined,
startStreet: startStreet ? `${startStreet}` : undefined,
startZip: startZip ? `${startZip}` : undefined,
startCity: startCity ? `${startCity}` : undefined,
targetInstitution: targetInstitution
? `${targetInstitution}`
: undefined,
targetStreet: targetStreet ? `${targetStreet}` : undefined,
targetCity: targetCity ? `${targetCity}` : undefined,
targetZip: targetZip ? `${targetZip}` : undefined,
transportKrz: transportKrz ? `${transportKrz}` : undefined,
transportName: transportName ? `${transportName}` : undefined,
transportart: transportart ? `${transportart}` : undefined,
verordnungsId: verordnungsId ? `${verordnungsId}` : undefined,
verordnungsName: verordnungsName
? `${verordnungsName}`
: undefined,
type: type ? `${type}` : undefined,
startBegin: startBegin ? `${startBegin}` : undefined,
startEnd: startEnd ? `${startEnd}` : undefined,
frei: frei ? `${frei}` : undefined,
ziel: ziel ? `${ziel}` : undefined,
zielBegin: zielBegin ? `${zielBegin}` : undefined,
endTime: endTime ? `${endTime}` : undefined,
endDate: endDate ? `${endDate}` : undefined,
einsatzNummer: einsatzNummer ? `${einsatzNummer}` : undefined,
carName: carName ? `${carName}` : undefined,
carNo: carNo ? `${carNo}` : undefined,
beifahrer: beifahrer ? `${beifahrer}` : undefined,
beifahrerName: beifahrerName ? `${beifahrerName}` : undefined,
fahrer: fahrer ? `${fahrer}` : undefined,
fahrerName: fahrerName ? `${fahrerName}` : undefined,
color: color ? `${color}` : undefined,
};
}),
), ),
); );
@ -326,7 +243,7 @@ export class DispoLiveExtractorsService {
!!qualifikation && typeof qualifikation === 'object' !!qualifikation && typeof qualifikation === 'object'
? //@ts-expect-error ts is wrong here ? //@ts-expect-error ts is wrong here
qualifikation?._id qualifikation?._id
: qualifikation ?? undefined, : (qualifikation ?? undefined),
}; };
}), }),
), ),

View File

@ -0,0 +1,141 @@
import { Prisma } from '@prisma/client';
import { TourRaw } from '../source.interface';
export class TourExtractor {
public static extract(tour: TourRaw): Partial<Prisma.TourSourceCreateInput> {
const {
_id,
check,
createTime,
lastUpdate,
empfangenVonId,
endgen,
serienId,
serienKategorName,
serienKategorie,
zadStatus,
abrDate,
abrNum,
abrTime,
ausrueckZeit,
startTime,
startDate,
date,
date_iso,
dauergenehmigung,
direction,
distanceValue,
besetztKm,
gefahren,
gesamtPreis,
infektion,
itDesc,
itKrz,
itName,
kkId,
krankenkasse,
patId,
patName,
patSurname,
patStreet,
patZip,
patCity,
startInstitution,
startStreet,
startZip,
startCity,
targetInstitution,
targetStreet,
targetCity,
targetZip,
transportKrz,
transportName,
transportart,
verordnungsId,
verordnungsName,
type,
startBegin,
startEnd,
frei,
ziel,
zielBegin,
endTime,
endDate,
einsatzNummer,
carName,
carNo,
beifahrer,
beifahrerName,
fahrer,
fahrerName,
color,
} = tour;
return {
id: _id ? `${_id}` : undefined,
check: check ? `${check}` : undefined,
createTime: createTime ? `${createTime}` : undefined,
lastUpdate: lastUpdate ? `${lastUpdate}` : undefined,
empfangenVonId: empfangenVonId ? `${empfangenVonId}` : undefined,
endgen: endgen ? `${endgen}` : undefined,
serienId: serienId ? `${serienId}` : undefined,
serienKategorName: serienKategorName ? `${serienKategorName}` : undefined,
serienKategorie: serienKategorie ? `${serienKategorie}` : undefined,
zadStatus: zadStatus ? `${zadStatus}` : undefined,
abrDate: abrDate ? `${abrDate}` : undefined,
abrNum: abrNum ? `${abrNum}` : undefined,
abrTime: abrTime ? `${abrTime}` : undefined,
ausrueckZeit: ausrueckZeit ? `${ausrueckZeit}` : undefined,
startTime: startTime ? `${startTime}` : undefined,
startDate: startDate ? `${startDate}` : undefined,
date: date ? `${date}` : undefined,
date_iso: date_iso ? `${date_iso}` : undefined,
dauergenehmigung: dauergenehmigung ? `${dauergenehmigung}` : undefined,
direction: direction ? `${direction}` : undefined,
distanceValue: distanceValue ? `${distanceValue}` : undefined,
besetztKm: besetztKm ? `${besetztKm}` : undefined,
gefahren: gefahren ? `${gefahren}` : undefined,
gesamtPreis: gesamtPreis ? `${gesamtPreis}` : undefined,
infektion: infektion ? `${infektion}` : undefined,
itDesc: itDesc ? `${itDesc}` : undefined,
itKrz: itKrz ? `${itKrz}` : undefined,
itName: itName ? `${itName}` : undefined,
kkId: kkId ? `${kkId}` : undefined,
krankenkasse: krankenkasse ? `${krankenkasse}` : undefined,
patId: patId ? `${patId}` : undefined,
patName: patName ? `${patName}` : undefined,
patSurname: patSurname ? `${patSurname}` : undefined,
patStreet: patStreet ? `${patStreet}` : undefined,
patZip: patZip ? `${patZip}` : undefined,
patCity: patCity ? `${patCity}` : undefined,
startInstitution: startInstitution ? `${startInstitution}` : undefined,
startStreet: startStreet ? `${startStreet}` : undefined,
startZip: startZip ? `${startZip}` : undefined,
startCity: startCity ? `${startCity}` : undefined,
targetInstitution: targetInstitution ? `${targetInstitution}` : undefined,
targetStreet: targetStreet ? `${targetStreet}` : undefined,
targetCity: targetCity ? `${targetCity}` : undefined,
targetZip: targetZip ? `${targetZip}` : undefined,
transportKrz: transportKrz ? `${transportKrz}` : undefined,
transportName: transportName ? `${transportName}` : undefined,
transportart: transportart ? `${transportart}` : undefined,
verordnungsId: verordnungsId ? `${verordnungsId}` : undefined,
verordnungsName: verordnungsName ? `${verordnungsName}` : undefined,
type: type ? `${type}` : undefined,
startBegin: startBegin ? `${startBegin}` : undefined,
startEnd: startEnd ? `${startEnd}` : undefined,
frei: frei ? `${frei}` : undefined,
ziel: ziel ? `${ziel}` : undefined,
zielBegin: zielBegin ? `${zielBegin}` : undefined,
endTime: endTime ? `${endTime}` : undefined,
endDate: endDate ? `${endDate}` : undefined,
einsatzNummer: einsatzNummer ? `${einsatzNummer}` : undefined,
carName: carName ? `${carName}` : undefined,
carNo: carNo ? `${carNo}` : undefined,
beifahrer: beifahrer ? `${beifahrer}` : undefined,
beifahrerName: beifahrerName ? `${beifahrerName}` : undefined,
fahrer: fahrer ? `${fahrer}` : undefined,
fahrerName: fahrerName ? `${fahrerName}` : undefined,
color: color ? `${color}` : undefined,
};
}
}

View File

@ -1,5 +1,6 @@
export interface TourRaw { export interface TourRaw {
_id?: string; _id?: string;
id?: string;
check?: string; check?: string;
createTime?: string; // yyyy-MM-dd HH:mm:ss 'Europe/Berlin' OR yyyy-MM-dd 'Europe/Berlin' createTime?: string; // yyyy-MM-dd HH:mm:ss 'Europe/Berlin' OR yyyy-MM-dd 'Europe/Berlin'
lastUpdate?: string; // yyyy-MM-dd HH:mm:ss 'Europe/Berlin' lastUpdate?: string; // yyyy-MM-dd HH:mm:ss 'Europe/Berlin'
@ -148,4 +149,32 @@ export interface TourSummaryDto {
einsatznummer: string; einsatznummer: string;
check: string; check: string;
lastUpdate?: string; lastUpdate?: string;
}
export interface FahrzeugplanungItemDto {
_id?: string;
className: string; // 'greenItem' OR 'blocked'
group: string; // Fahrzeug ID
mitarbeiterId: string; // Mitarbeiter ID
start: string; // ISO 8601 TS (UTC)
end: string; // ISO 8601 TS (UTC)
}
export interface FahrzeugItemDto {
_id: string
bezeichnung: string
marke: string
tuev: string
conn: string
carStatus: string
kaufDatum: string
erstzulassung: string
status: string
firmaId: string
kennzeichen: string
kaufPreis: string
kaufKm: string
fahrzeugIdentNr: string
kfzNr: string
verordnungsart: string
} }

View File

@ -1,12 +1,15 @@
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TOURS_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import { FeatBusinessObjectsModule } from 'src/modules/feat-business-objects/feat-business-objects.module'; import { FeatBusinessObjectsModule } from 'src/modules/feat-business-objects/feat-business-objects.module';
import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module'; import { DataAccessExtractorsModule } from '../feat-extractors/data-access-extractors.module';
import { DataAccessLoadersModule } from '../feat-loaders/data-access-loaders.module'; import { DataAccessLoadersModule } from '../feat-loaders/data-access-loaders.module';
import { DataAccessTransformersModule } from '../feat-transformers/data-access-transformers.module'; import { DataAccessTransformersModule } from '../feat-transformers/data-access-transformers.module';
import { ImporterService } from './importer.service'; import { ImporterService } from './importer.service';
import { SchichtplanungConsumer } from './processors/schichtplanung.consumer'; import { SchichtplanungConsumer } from './processors/schichtplanung.consumer';
import { ToursConsumer } from './processors/tours.consumer';
import { SchichtplanungSynchronizer } from './producer/schichtplanung'; import { SchichtplanungSynchronizer } from './producer/schichtplanung';
import { TourUpdateChecker } from './producer/tour-update';
import { SCHICHTPLANUNG_QUEUE } from './tokens'; import { SCHICHTPLANUNG_QUEUE } from './tokens';
@Module({ @Module({
@ -15,10 +18,13 @@ import { SCHICHTPLANUNG_QUEUE } from './tokens';
DataAccessExtractorsModule, DataAccessExtractorsModule,
DataAccessTransformersModule, DataAccessTransformersModule,
DataAccessLoadersModule, DataAccessLoadersModule,
BullModule.registerQueue({ name: TOURS_QUEUE }),
BullModule.registerQueue({ name: SCHICHTPLANUNG_QUEUE }), BullModule.registerQueue({ name: SCHICHTPLANUNG_QUEUE }),
], ],
providers: [ providers: [
ImporterService, ImporterService,
ToursConsumer,
TourUpdateChecker,
SchichtplanungSynchronizer, SchichtplanungSynchronizer,
SchichtplanungConsumer, SchichtplanungConsumer,
], ],

View File

@ -11,6 +11,7 @@ import { ETLEventTypes } from './events/event-types.enum';
import { ExtractionDoneEvent } from './events/extraction-done.event'; import { ExtractionDoneEvent } from './events/extraction-done.event';
import { LoadingDoneEvent } from './events/loading-done.event'; import { LoadingDoneEvent } from './events/loading-done.event';
import { TransformationDoneEvent } from './events/transformation-done.event'; import { TransformationDoneEvent } from './events/transformation-done.event';
import { TourUpdateChecker } from './producer/tour-update';
import { SchichtplanungSynchronizer } from './producer/schichtplanung'; import { SchichtplanungSynchronizer } from './producer/schichtplanung';
@Injectable() @Injectable()
@ -21,9 +22,13 @@ export class ImporterService {
private readonly transformersService: TransformersService, private readonly transformersService: TransformersService,
private readonly loadersService: LoadersService, private readonly loadersService: LoadersService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly tourUpdateChecker: TourUpdateChecker,
private readonly schichtplanungSynchronizer: SchichtplanungSynchronizer, private readonly schichtplanungSynchronizer: SchichtplanungSynchronizer,
) {} ) {}
runTourUpdateCheck(interval: Interval) {
return this.tourUpdateChecker.fetchTourUpdates(interval);
}
runSchichtplanungSync(day: Date) { runSchichtplanungSync(day: Date) {
return this.schichtplanungSynchronizer.syncSchichtplanung(day); return this.schichtplanungSynchronizer.syncSchichtplanung(day);

View File

@ -0,0 +1,63 @@
import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job, Queue, Worker } from 'bullmq';
import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service';
import { TOURS_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import { DispoLiveExtractorsService } from '../../feat-extractors/dispo-live-extractors.service';
import { firstValueFrom, from, lastValueFrom, map, switchMap, tap } from 'rxjs';
import { TourTransformer } from '../../feat-transformers/transformers/tour.transformer';
import { TourSource } from '@prisma/client';
import { ToursCalculationAdapater } from '../../feat-transformers/adapter/tours-calculation.adapter';
import { Logger } from '@nestjs/common';
@Processor(TOURS_QUEUE, {
limiter: {
duration: 1000,
max: 10,
},
})
export class ToursConsumer extends WorkerHost {
private readonly logger = new Logger(ToursConsumer.name);
constructor(
private readonly service: TourService,
private readonly apiService: DispoLiveExtractorsService,
private readonly tourCalculationAdapter: ToursCalculationAdapater,
) {
super();
}
async process(job: Job, token?: string): Promise<any> {
let isCreate = false;
switch (job.name) {
case 'Create':
isCreate = true;
case 'Update':
this.logger.log(`Working on Job(${job.id}): ${job.data?.id}`);
const tour = await lastValueFrom(
this.apiService.extractTour(job.data?.id, job.id).pipe(
map((tour) =>
TourTransformer.transform({
...tour,
id: tour._id ?? tour.id,
insertedAt: new Date(),
internalId: tour.jobId,
} as TourSource),
),
switchMap((tour) =>
from(
this.tourCalculationAdapter.addRevenueAndConsumptionCostsToTours(
[tour],
),
).pipe(map(([tour]) => tour)),
),
),
);
if (isCreate || job.name === 'Create') {
return await this.service.createTour(tour).then(() => this.logger.log(`Created tour ${tour.id} successfully`));
}
return await this.service.updateTour(tour.id, tour).then(() => this.logger.log(`Updated tour ${tour.id} successfully`));
default:
console.log(`No case matched`, job);
}
}
}

View File

@ -0,0 +1,92 @@
import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bullmq';
import { Interval } from 'date-fns';
import { catchError, from, map, switchMap, tap, throwError } from 'rxjs';
import { TourService } from 'src/modules/feat-business-objects/entities/tour/tour.service';
import { TOURS_QUEUE } from 'src/modules/feat-business-objects/entities/tour/tour.tokens';
import { DispoLiveApiExtractorsService } from '../../feat-extractors/dispo-live-api-extractors.service';
import {
parseCETDateTime,
pick,
transformProperties,
} from '../../feat-transformers/transformer.functions';
@Injectable()
export class TourUpdateChecker {
constructor(
private readonly tourService: TourService,
private readonly dispoLiveApiService: DispoLiveApiExtractorsService,
@InjectQueue(TOURS_QUEUE) private readonly toursQueue: Queue,
) {}
async fetchTourUpdates(interval: Interval) {
return await this.dispoLiveApiService.toursFindByRange(interval).pipe(
switchMap((summaries) =>
from(
this.tourService.findUpdatedAtBetween(
summaries.data.at(0)._id,
summaries.data.at(-1)._id,
),
).pipe(
map((savedTours) => {
const deleted = [];
const updated = [];
const created = [];
for (const summary of summaries.data) {
const savedTour = savedTours.find(({ id }) => summary._id === id);
if (!savedTour) created.push({ id: summary._id });
const lastUpdateDate = summary?.lastUpdate
? transformProperties(parseCETDateTime('yyyy-MM-dd HH:mm:ss'))(
pick(['lastUpdate'])(
summary as Record<'lastUpdate', string>,
),
)
: undefined;
if (
lastUpdateDate?.lastUpdate.toISOString() !==
savedTour?.updatedAt?.toISOString()
) {
updated.push({ id: summary._id });
}
}
for (const savedTour of savedTours) {
const summary = summaries.data.find(
({ _id }) => savedTour.id === _id,
);
if (!summary) deleted.push({ id: savedTour.id });
}
return {
created,
updated,
deleted,
};
}),
switchMap(({ created, updated, deleted }) =>
from(
Promise.all([
...created.map(
async (summary) =>
await this.toursQueue.add(`Create`, summary),
),
...updated.map(
async (summary) =>
await this.toursQueue.add(`Update`, summary),
),
this.tourService.deleteTours(deleted.map(({ id }) => id)),
]),
),
),
),
),
catchError((err) => {
console.log(err);
return throwError(() => new Error('Something went wrong'));
}),
);
}
}

View File

@ -9,6 +9,6 @@ import { TransformersService } from './transformers.service';
@Module({ @Module({
imports: [PrismaModule, DataAccessExtractorsModule, FeatCalculationModule], imports: [PrismaModule, DataAccessExtractorsModule, FeatCalculationModule],
providers: [TransformersService, StagingRepository, ToursCalculationAdapater], providers: [TransformersService, StagingRepository, ToursCalculationAdapater],
exports: [TransformersService], exports: [TransformersService, ToursCalculationAdapater],
}) })
export class DataAccessTransformersModule {} export class DataAccessTransformersModule {}