feat: add associated entities to anomalies

This commit is contained in:
Marcel Arndt
2025-06-26 09:04:53 +02:00
parent b31df96a44
commit d9b615fd47
38 changed files with 1094 additions and 133 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Anomaly" ADD COLUMN "associatedEntities" JSONB DEFAULT '{}';
@@ -508,7 +508,7 @@ model Employee {
}
model DyflexisEmployee {
id Int @id
id Int @id
firstname String
surname String
contractStart DateTime
@@ -580,18 +580,19 @@ model Trace {
}
model Anomaly {
id String @id
type String
description String
groupKey String
groupDescription String
workedOnBy String
sleepTimer Int
sleepSince DateTime?
solution SolutionTarget[] @relation()
resolvedAt DateTime?
manuallyResolved Boolean @default(false)
createdAt DateTime @default(now())
id String @id
type String
description String
groupKey String
groupDescription String
associatedEntities Json? @default("{}")
workedOnBy String
sleepTimer Int
sleepSince DateTime?
solution SolutionTarget[] @relation()
resolvedAt DateTime?
manuallyResolved Boolean @default(false)
createdAt DateTime @default(now())
}
model SolutionTarget {
@@ -55,6 +55,22 @@ enum ApprovalState {
UNKNOWN
}
type AssociatedEntities {
attendanceRegistration: [AttendanceRegistration!]
employee: [Employee!]
plannedTime: [PlannedTime!]
tour: [Tour!]
workTime: [WorkTime!]
}
type AttendanceRegistration {
attendanceRegistrationId: Int!
dateTime: DateTime!
employeeId: Int!
event: String!
personnelNumber: String!
}
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
@@ -65,6 +81,16 @@ type DocumentInfo {
tdLocation: String
}
type Employee {
id: String!
name: String!
occuptationalHealthExamination: DateTime
personTransportCertificate: DateTime
personnelNumber: String!
qualification: String
surname: String!
}
type HomeDashboardKPIs {
anomalyCount: Int!
billableTourCount: Int!
@@ -148,12 +174,26 @@ type PaginatedTour {
totalCount: Int!
}
type PlannedTime {
department: String!
duration: Int!
endDate: DateTime!
firstname: String!
id: String!
note: String
pause: Int!
startDate: DateTime!
surname: String!
userId: String!
}
type Query {
accountingStateMeta(state: AccountingState!): TourStateMeta!
accountingTours(cursor: String, filters: AccountingFilterArgs, state: AccountingState!, take: Int! = 10): PaginatedTour!
anomalies(cursor: String, take: Int! = 10): PaginatedAnomaly!
anomaliesProto: [Anomaly!]!
anomaly(id: String!): Anomaly!
associatedEntities(id: String!): AssociatedEntities!
groupedAnomalies(cursor: String, take: Int! = 10): PaginatedAnomalyGroup!
homeDashboardKpis: HomeDashboardKPIs!
managerKpi(ordinanceType: String!): KpiInfo!
@@ -337,4 +377,16 @@ type TourStateMeta {
negativeRevenueDeviation: Float
positiveRevenueDeviation: Float
revenueSum: Float
}
type WorkTime {
department: String!
duration: Int!
endDate: DateTime!
firstname: String!
id: String!
pause: Int!
startDate: DateTime!
surname: String!
userId: String!
}
@@ -48,6 +48,7 @@ export type Anomaly = {
description: string;
groupKey: string;
groupDescription: string;
associatedEntities: Generated<unknown | null>;
workedOnBy: string;
sleepTimer: number;
sleepSince: Timestamp | null;
@@ -1,6 +1,115 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Anomaly, SolutionTarget, SolutionTargetStep } from '@prisma/client';
import {
Anomaly,
AttendanceRegistration,
Employee,
PlannedTime,
SolutionTarget,
SolutionTargetStep,
WorkTime,
} from '@prisma/client';
import { JsonValue } from '@prisma/client/runtime/library';
import { Paginated } from 'src/core/base/pagination.input';
import { AnomalyAsssociatedEntities } from '../feat-business-objects/entities/anomaly/anomalies.dto';
import { TourObjectType } from '../app-accounting/tour.object-type';
@ObjectType('PlannedTime')
export class PlannedTimeObjectType implements PlannedTime {
@Field()
id: string;
@Field()
userId: string;
@Field()
firstname: string;
@Field()
surname: string;
@Field(() => Date)
startDate: Date;
@Field(() => Date)
endDate: Date;
@Field()
department: string;
@Field(() => Int)
pause: number;
@Field(() => Int)
duration: number;
@Field({nullable: true})
note: string;
}
@ObjectType('WorkTime')
export class WorkTimeObjectType implements WorkTime {
@Field()
id: string;
@Field()
userId: string;
@Field()
firstname: string;
@Field()
surname: string;
@Field(() => Date)
startDate: Date;
@Field(() => Date)
endDate: Date;
@Field()
department: string;
@Field(() => Int)
pause: number;
@Field(() => Int)
duration: number;
}
@ObjectType('Employee')
export class EmployeeObjectType implements Employee {
@Field()
name: string;
@Field()
id: string;
@Field()
surname: string;
@Field()
personnelNumber: string;
// @Field()
mobile: string;
@Field({ nullable: true })
qualification: string;
@Field(() => Date, { nullable: true })
occuptationalHealthExamination: Date;
@Field(() => Date, { nullable: true })
personTransportCertificate: Date;
}
@ObjectType('AttendanceRegistration')
export class AttendanceRegistrationObjectType
implements AttendanceRegistration
{
@Field()
personnelNumber: string;
@Field(() => Int)
attendanceRegistrationId: number;
@Field(() => Int)
employeeId: number;
@Field(() => Date)
dateTime: Date;
@Field()
event: string;
}
@ObjectType('AssociatedEntities')
export class AssociatedEntitiesObjectType
implements AnomalyAsssociatedEntities
{
@Field(() => [TourObjectType], { nullable: true })
tour?: TourObjectType[];
@Field(() => [EmployeeObjectType], { nullable: true })
employee?: EmployeeObjectType[];
@Field(() => [PlannedTimeObjectType], { nullable: true })
plannedTime?: PlannedTimeObjectType[];
@Field(() => [WorkTimeObjectType], { nullable: true })
workTime?: WorkTimeObjectType[];
@Field(() => [AttendanceRegistrationObjectType], { nullable: true })
attendanceRegistration?: AttendanceRegistrationObjectType[];
}
@ObjectType('SolutionTarget')
export class SolutionTargetObjectType implements SolutionTarget {
@@ -32,6 +141,8 @@ export class AnomalyObjectType implements Anomaly {
groupKey: string;
@Field()
groupDescription: string;
// @Field()
associatedEntities: JsonValue;
@Field()
workedOnBy: string;
@Field(() => Int)
@@ -18,6 +18,7 @@ import { AnomalyService } from '../feat-business-objects/entities/anomaly/anomal
import { KpiService } from '../feat-kpis/kpi.service';
import {
AnomalyObjectType,
AssociatedEntitiesObjectType,
PaginatedAnomalyGroupObjectType,
PaginatedAnomalyObjectType,
SolutionTargetObjectType,
@@ -99,6 +100,11 @@ export class ControlCenterResolver {
return this.anomalyService.acceptAnomaly(anomalyId);
}
@Query(() => AssociatedEntitiesObjectType)
async associatedEntities(@Args({ name: 'id' }) anomalyId: string) {
return this.anomalyService.getAssociatedEntities(anomalyId);
}
//! Used when creating anomaly list
// @ResolveField(() => TourObjectType, { nullable: true })
// tour(@Parent() anomaly: AnomalyObjectType) {
@@ -5,9 +5,15 @@ import { AnomalyDetectorService } from './anomaly-detector.service';
export type AnomalySkeleton = Pick<
Prisma.AnomalyCreateInput,
'createdAt' | 'workedOnBy' | 'sleepTimer' | 'sleepSince'
| 'associatedEntities'
| 'createdAt'
| 'workedOnBy'
| 'sleepTimer'
| 'sleepSince'
>;
export abstract class AnomalyDetector {
private associatedEntities: Record<string, { id: string }[]> = {};
constructor(protected readonly dataService: AnomalyDetectorService) {}
/**
* Unique identifier of the Anomaly Type which is implemented by the class
@@ -25,8 +31,28 @@ export abstract class AnomalyDetector {
abstract createDescription(data: unknown): string;
abstract createSolution(data: unknown): Prisma.SolutionTargetCreateInput[];
protected addAssociatedEntity(type: string, id: string): void {
this.associatedEntities = {
...this.associatedEntities,
[type]: [
...(this.associatedEntities[type]?.length
? this.associatedEntities[type]
: []),
{ id },
],
};
}
protected flushAssociatedEntities(): void {
this.associatedEntities = {};
}
protected getAnomalySkeleton(): AnomalySkeleton {
const associatedEntities = this.associatedEntities;
this.flushAssociatedEntities();
return {
associatedEntities,
createdAt: new Date(),
workedOnBy: '',
sleepTimer: 60 * 60 * 1000,
@@ -89,7 +89,8 @@ export class AnomalyDetectorService {
],
): Promise<AttendanceRegistration[]> {
let startOfGivenDay: Date;
if (day.getHours() >= 0 && day.getHours() < 3) {
// TODO: Is there a better solution? This seems a bit flacky
if (day.getHours() >= 0 && day.getHours() < 2) {
startOfGivenDay = addDays(day, -1);
} else {
startOfGivenDay = startOfDay(day);
@@ -17,7 +17,14 @@ export class DriverCertificateDetector extends AnomalyDetector {
toursInRange
.filter(({ driverId }) => !!driverId)
.map(
async ({ driverId, startDate, driverName, carName, operationId }) => {
async ({
id: tourId,
driverId,
startDate,
driverName,
carName,
operationId,
}) => {
try {
const driverEmployee =
await this.dataService.findEmployeeById(driverId);
@@ -37,6 +44,9 @@ export class DriverCertificateDetector extends AnomalyDetector {
startDate,
)
) {
this.addAssociatedEntity('employee', driverEmployee.id);
this.addAssociatedEntity('tour', tourId);
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
operationId,
@@ -17,8 +17,18 @@ export class DriverCodriverSameDetector extends AnomalyDetector {
const anomalies = await Promise.all(
toursInRange.map(
({ operationId, carName, startDate, driverId, codriverId }) => {
if (driverId === codriverId) {
({
id: tourId,
operationId,
carName,
startDate,
driverId,
codriverId,
}) => {
if (driverId === codriverId && driverId !== null) {
this.addAssociatedEntity('tour', tourId);
this.addAssociatedEntity('employee', driverId);
return this.createAnomaly(
`${operationId}-${this.type}`,
operationId,
@@ -152,6 +152,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
private async detectAnomalyForEmployee(
{
id: tourId,
driverId,
codriverId,
startDate,
@@ -174,15 +175,15 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
const startOfTour = this.getStartOfTour(
new Date(startDate),
[startBegin, startEnd, target, targetBegin, empty].filter(
(val) => !!val,
)[0],
[startBegin, startEnd, target, targetBegin, empty]
.filter((val) => !!val)
.at(0),
);
if (!startOfTour) return Promise.resolve(undefined);
if (this.validateEmployee(employee)) {
const [isEmployeeLoggedIn, checkedInterval] =
const [isEmployeeLoggedIn, checkedInterval, associatedEntities] =
await this.isEmployeeLoggedInAtDateTime(
employee.personnelNumber,
startOfTour,
@@ -190,8 +191,14 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
if (!isEmployeeLoggedIn) {
const employeeName = `${employee.name} ${employee.surname}`;
this.addAssociatedEntity('employee', employee.id);
this.addAssociatedEntity('tour', tourId);
Object.entries(associatedEntities).map(([type, entityIds]) =>
entityIds.map((id) => this.addAssociatedEntity(type, id)),
);
return this.createAnomaly(
`${operationId}-${driverId}-${this.type}`,
`${operationId}-${employeeId}-${this.type}`,
operationId,
employeeName,
employee.mobile,
@@ -258,7 +265,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
private async isEmployeeLoggedInAtDateTime(
personnelNumber: string,
datetimeToCheck: Date,
): Promise<[boolean, Interval]> {
): Promise<[boolean, Interval, Record<string, string[]>]> {
const workTime = await this.dataService.findWorkTimeByPersonnelNumberForDay(
personnelNumber,
datetimeToCheck,
@@ -288,6 +295,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
isWithinInterval(datetimeToCheckWithBuffer, interval) ||
isWithinInterval(datetimeToCheck, interval),
interval,
{ workTime: [workTime.id] },
];
} else if (
firstClockedIn &&
@@ -300,6 +308,12 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
start: firstClockedIn.dateTime,
end: undefined,
},
{
attendanceRegistration: [
`${firstClockedIn.attendanceRegistrationId}`,
`${lastClockedOut.attendanceRegistrationId}`,
],
},
];
} else if (firstClockedIn && lastClockedOut) {
const interval = {
@@ -310,6 +324,12 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
isWithinInterval(datetimeToCheckWithBuffer, interval) ||
isWithinInterval(datetimeToCheck, interval),
interval,
{
attendanceRegistration: [
`${firstClockedIn.attendanceRegistrationId}`,
`${lastClockedOut.attendanceRegistrationId}`,
],
},
];
} else if (firstClockedIn && !lastClockedOut) {
return [
@@ -319,9 +339,14 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
datetimeToCheckWithBuffer,
),
{ start: firstClockedIn.dateTime, end: undefined },
{
attendanceRegistration: [
`${firstClockedIn.attendanceRegistrationId}`,
],
},
];
} else {
return [false, undefined];
return [false, undefined, {}];
}
} catch (error) {
this.logger.error(error, {
@@ -330,7 +355,7 @@ export class EmployeesLoggedInDetector extends AnomalyDetector {
firstClockedIn,
lastClockedOut,
});
return [false, undefined];
return [false, undefined, {}];
}
}
@@ -57,6 +57,15 @@ export class WorkingEmployeeNotInDistributionSystemDetector extends AnomalyDetec
employee.id,
);
if (toursWithEmployee.length === 0) {
this.addAssociatedEntity(
'attendanceRegistration',
`${attendanceRegistrationId}`,
);
this.addAssociatedEntity('employee', employee.id);
plannedTime.map(({ id }) =>
this.addAssociatedEntity('plannedTime', id),
);
return this.createAnomaly(
`${attendanceRegistrationId}-${employee.id}`,
format(new Date(dateTime), 'yyyy-MM-dd'),
@@ -1,3 +1,11 @@
import {
AttendanceRegistration,
Employee,
PlannedTime,
Tour,
WorkTime,
} from '@prisma/client';
export interface AnomalyCreateDto {
id: string;
type: string;
@@ -25,3 +33,11 @@ export interface AnomalyUpdateDto {
resolvedAt: Date;
manuallyResolved: boolean;
}
export interface AnomalyAsssociatedEntities {
tour?: Tour[];
plannedTime?: PlannedTime[];
workTime?: WorkTime[];
attendanceRegistration?: AttendanceRegistration[];
employee?: Employee[];
}
@@ -1,12 +1,26 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Anomaly, SolutionTarget, SolutionTargetStep } from '@prisma/client';
import { AnomalyCreateDto } from './anomalies.dto';
import { AnomalyAsssociatedEntities, AnomalyCreateDto } from './anomalies.dto';
import { AnomalyTypes } from './anomaly-types.enum';
import { AnomalyRepository } from './anomaly.repository';
import { TourService } from '../tour/tour.service';
import { EmployeeService } from '../employee/employee.service';
import { PlannedTimeService } from '../planned-time/planned-time.service';
import { WorkTimeService } from '../work-time/work-time.service';
import { AttendanceRegistrationService } from '../attendance-registration/attendance-registration.service';
@Injectable()
export class AnomalyService {
constructor(private readonly repository: AnomalyRepository) {}
private logger = new Logger(AnomalyService.name);
constructor(
private readonly repository: AnomalyRepository,
private readonly attendanceRegistrationService: AttendanceRegistrationService,
private readonly employeeService: EmployeeService,
private readonly plannedTimeService: PlannedTimeService,
private readonly tourService: TourService,
private readonly workTimeService: WorkTimeService,
) {}
async createEvenIfExists(anomaly: AnomalyCreateDto): Promise<Anomaly> {
return this.repository.createEvenIfExists(anomaly);
@@ -44,6 +58,54 @@ export class AnomalyService {
return this.repository.countVisibleAnomalies(types);
}
async getAssociatedEntities(
anomalyId: string,
): Promise<AnomalyAsssociatedEntities> {
const { associatedEntities } = await this.findById(anomalyId);
const entityList = await Promise.all(
Object.entries(associatedEntities).map(
async ([type, ids]: [string, { id: string }[]]) => {
switch (type) {
case 'tour':
return Promise.all(
ids.map(({ id }) => this.tourService.findById(id)),
).then((tour) => ({ tour }));
case 'plannedTime':
return Promise.all(
ids.map(({ id }) => this.plannedTimeService.findById(id)),
).then((plannedTime) => ({ plannedTime }));
case 'workTime':
return Promise.all(
ids.map(({ id }) => this.workTimeService.findById(id)),
).then((workTime) => ({ workTime }));
case 'attendanceRegistration':
return Promise.all(
ids.map(({ id }) =>
this.attendanceRegistrationService.findById(+id),
),
).then((attendanceRegistration) => ({ attendanceRegistration }));
case 'employee':
return Promise.all(
ids.map(({ id }) => this.employeeService.findById(id)),
).then((employee) => ({ employee }));
default:
this.logger.error(
`Associated Entity type not implemented: ${type}`,
);
}
},
),
);
return entityList.reduce(
(sum, cur) => ({
...sum,
...cur,
}),
{},
);
}
async findSolutionTargetsByAnomalyId(
anomalyId: string,
): Promise<SolutionTarget[]> {
@@ -7,6 +7,12 @@ import { PrismaService } from 'src/core/database/prisma.service';
export class PlannedTimeRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<PlannedTime> {
return await this.prisma.plannedTime.findUnique({
where: { id },
});
}
async findByPersonnelNumberInInterval(
personnelNumber: string,
interval: Interval,
@@ -7,6 +7,10 @@ import { PlannedTimeRepository } from './planned-time.repository';
export class PlannedTimeService {
constructor(private readonly repository: PlannedTimeRepository) {}
async findById(id: string): Promise<PlannedTime> {
return this.repository.findById(id);
}
async findByPersonnelNumberInInterval(
personnelNumber: string,
interval: Interval,
@@ -80,6 +80,12 @@ export class TourRepository {
});
}
async findById(tourId: string): Promise<Tour> {
return await this.prisma.tour.findUnique({
where: { id: tourId },
});
}
async findInInterval(
interval: Interval,
ordinanceTypes: string[] = [],
@@ -28,6 +28,10 @@ export class TourService {
);
}
async findById(tourId: string): Promise<Tour> {
return this.repository.findById(tourId);
}
async findByOperationId(operationId: string): Promise<Tour> {
return this.repository.findByOperationId(operationId);
}
@@ -7,6 +7,12 @@ import { PrismaService } from 'src/core/database/prisma.service';
export class WorkTimeRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<WorkTime> {
return await this.prisma.workTime.findUnique({
where: { id },
});
}
async findByPersonnelNumberInInterval(
personnelNumber: string,
interval: Interval,
@@ -7,6 +7,10 @@ import { WorkTimeRepository } from './work-time.repository';
export class WorkTimeService {
constructor(private readonly repository: WorkTimeRepository) {}
async findById(id: string): Promise<WorkTime> {
return this.repository.findById(id);
}
async findByPersonnelNumberInInterval(
personnelNumber: string,
interval: Interval,
@@ -316,6 +316,10 @@ export class KpiService {
include: {
state: true,
},
orderBy: {
id: 'asc'
},
take: 1000
});
const timePerTickets = movedTickets
.map(({ state }) => {