mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Added assertCapacity() checks before saving (matches single receive) Added applyCapacityDelta() after save to increment counters Now validates warehouse → yard → zone capacity hierarchy Single receive already had both checks; bulk receive was gap.
185 lines
7.6 KiB
TypeScript
185 lines
7.6 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
|
import { DataSource, Repository } from 'typeorm';
|
|
import { MaintenanceRepository } from './maintenance.repository';
|
|
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
|
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
|
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
|
|
|
@Injectable()
|
|
export class MaintenanceService {
|
|
private readonly logger = new Logger(MaintenanceService.name);
|
|
|
|
constructor(
|
|
private readonly maintenanceRepository: MaintenanceRepository,
|
|
@InjectRepository(MaintenanceSchedule)
|
|
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
|
@InjectRepository(MaintenanceCost)
|
|
private readonly costRepository: Repository<MaintenanceCost>,
|
|
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
|
// reach it through the global DataSource rather than @InjectRepository.
|
|
private readonly dataSource: DataSource,
|
|
private readonly inbox: NotificationInboxService,
|
|
) {}
|
|
|
|
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
|
|
async getDueBoard() {
|
|
return this.maintenanceRepository.getDueBoard();
|
|
}
|
|
|
|
/**
|
|
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
|
|
* that's the only place mileage is actually recorded) or its due date has
|
|
* reached a SCHEDULED item's threshold → alert backoffice once.
|
|
*/
|
|
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
|
|
async sendDueAlerts(): Promise<void> {
|
|
try {
|
|
const due = await this.maintenanceRepository.getUnnotifiedDue();
|
|
for (const item of due) {
|
|
const reason =
|
|
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
|
|
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
|
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
|
await this.inbox.notify({
|
|
recipients: { allBackoffice: true },
|
|
audience: NotificationAudience.BACKOFFICE,
|
|
type: NotificationType.GENERIC,
|
|
title: `Maintenance due — ${item.plateNumber}`,
|
|
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
|
|
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
|
|
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
|
|
});
|
|
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
|
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
|
* maintenance completes or is cancelled it returns to service (ACTIVE + FREE).
|
|
* Only the vehicle's status/availability columns are written here. The
|
|
* assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles)
|
|
* lives in those excluded mile modules, not here.
|
|
*/
|
|
private async setVehicleMaintenanceState(
|
|
vehicleId: string,
|
|
underMaintenance: boolean,
|
|
): Promise<void> {
|
|
await this.dataSource.getRepository(Vehicle).update(vehicleId, {
|
|
status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE,
|
|
availability: underMaintenance
|
|
? VehicleAvailability.BUSY
|
|
: VehicleAvailability.FREE,
|
|
});
|
|
}
|
|
|
|
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
|
|
const schedule = this.scheduleRepository.create({
|
|
...dto,
|
|
scheduledDate: new Date(dto.scheduledDate),
|
|
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
|
|
});
|
|
const saved = await this.scheduleRepository.save(schedule);
|
|
// Scheduling maintenance takes the vehicle out of the available pool.
|
|
await this.setVehicleMaintenanceState(saved.vehicleId, true);
|
|
return saved;
|
|
}
|
|
|
|
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
|
|
const cost = this.costRepository.create({
|
|
...dto,
|
|
incurredDate: new Date(dto.incurredDate),
|
|
});
|
|
return this.costRepository.save(cost);
|
|
}
|
|
|
|
async updateMaintenanceSchedule(
|
|
id: string,
|
|
dto: UpdateMaintenanceScheduleDto,
|
|
): Promise<MaintenanceSchedule> {
|
|
await this.scheduleRepository.update(id, {
|
|
...dto,
|
|
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
|
});
|
|
const updated = await this.scheduleRepository.findOneBy({ id });
|
|
|
|
// Keep the vehicle's status/availability in step with the schedule status.
|
|
if (updated && dto.status) {
|
|
if (
|
|
dto.status === MaintenanceStatus.COMPLETED ||
|
|
dto.status === MaintenanceStatus.CANCELLED
|
|
) {
|
|
// Maintenance finished/aborted → vehicle back in service.
|
|
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
|
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
|
// Maintenance started → keep the vehicle out of service.
|
|
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
|
}
|
|
}
|
|
|
|
return updated!;
|
|
}
|
|
|
|
async getUpcomingMaintenance(vehicleId: string) {
|
|
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
|
|
}
|
|
|
|
async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) {
|
|
const endDate = new Date();
|
|
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
|
return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
|
|
}
|
|
|
|
async getFleetMaintenanceStats(monthsBack: number = 12) {
|
|
const endDate = new Date();
|
|
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
|
|
|
const costs = await this.costRepository
|
|
.createQueryBuilder('cost')
|
|
.where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate })
|
|
.getMany();
|
|
|
|
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
|
|
|
|
return {
|
|
totalCost,
|
|
numberOfMaintenanceItems: costs.length,
|
|
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
|
|
costByType: this.groupCostsByType(costs),
|
|
};
|
|
}
|
|
|
|
async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) {
|
|
const endDate = new Date();
|
|
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
|
|
|
|
const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
|
|
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
|
|
|
|
return {
|
|
vehicleId,
|
|
totalCost,
|
|
numberOfMaintenanceItems: costs.length,
|
|
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
|
|
costByType: this.groupCostsByType(costs),
|
|
};
|
|
}
|
|
|
|
private groupCostsByType(costs: MaintenanceCost[]) {
|
|
const grouped: Record<string, number> = {};
|
|
costs.forEach((c) => {
|
|
if (!grouped[c.costType]) grouped[c.costType] = 0;
|
|
grouped[c.costType] += Number(c.costAmount);
|
|
});
|
|
return grouped;
|
|
}
|
|
}
|