mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 16:00:56 +00:00
Export interchange document — a generation error after Djibouti unloading failed the whole request and left the document missing until a manual rerun. Generation is now best-effort (unload never fails on paperwork) and reruns backfill the document.
This commit is contained in:
@@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto {
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matched against the interval for auto-scheduling. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto {
|
||||
@IsNumber()
|
||||
actualCost?: number;
|
||||
|
||||
/** Odometer at completion — drives KM-based auto-scheduling of the next service. */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
odometerReading?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpsertMaintenanceIntervalDto {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
@IsEnum(MaintenanceType)
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
serviceItem?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalKm?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
intervalDays?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm';
|
||||
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { MaintenanceType } from './maintenance-schedule.entity';
|
||||
|
||||
/**
|
||||
* Maintenance interval configuration. Defines how often a vehicle/type needs maintenance.
|
||||
* Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km).
|
||||
* Maintenance interval configuration. Defines how often a vehicle needs a
|
||||
* given service. Identity is (vehicle, maintenanceType, serviceItem) — a
|
||||
* vehicle carries several intervals of the same coarse type with different
|
||||
* items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness
|
||||
* is enforced by a COALESCE expression index in the migration (nullable
|
||||
* service_item), not a TypeORM @Unique.
|
||||
*/
|
||||
@Entity({ name: 'maintenance_intervals', schema: 'freight' })
|
||||
@Index(['vehicleId', 'maintenanceType'])
|
||||
@Unique(['vehicleId', 'maintenanceType'])
|
||||
export class MaintenanceInterval extends BaseEntity {
|
||||
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||
vehicleId!: string;
|
||||
@@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity {
|
||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — "oil change", "tires", … Null = generic for the type. */
|
||||
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||
serviceItem?: string | null;
|
||||
|
||||
/** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */
|
||||
@Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
intervalKm?: number | null;
|
||||
|
||||
@@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity {
|
||||
@Column({ name: 'maintenance_type', type: 'varchar' })
|
||||
maintenanceType!: MaintenanceType;
|
||||
|
||||
/** What is serviced — matches the interval's service_item for auto-scheduling. */
|
||||
@Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true })
|
||||
serviceItem?: string | null;
|
||||
|
||||
@Column({ name: 'description' })
|
||||
description!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
|
||||
/**
|
||||
* KM-based auto-scheduling: completing a maintenance with an odometer reading
|
||||
* creates the next SCHEDULED item at completedKm + intervalKm, matched on the
|
||||
* schedule's (type, serviceItem) interval. Re-completing must not duplicate.
|
||||
*/
|
||||
function makeService(opts: {
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
interval: Record<string, unknown> | null;
|
||||
}) {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
const service = Object.create(MaintenanceService.prototype) as Record<string, unknown>;
|
||||
service.scheduleRepository = {
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(opts.before)
|
||||
.mockResolvedValueOnce(opts.after),
|
||||
update: jest.fn(),
|
||||
create: jest.fn((v: Record<string, unknown>) => v),
|
||||
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||
saved.push(v);
|
||||
return v;
|
||||
}),
|
||||
};
|
||||
service.intervalRepository = {
|
||||
getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval),
|
||||
};
|
||||
service.dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue({ update: jest.fn() }),
|
||||
};
|
||||
service.logger = { error: jest.fn() };
|
||||
return { service: service as unknown as MaintenanceService, saved };
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: 's-1',
|
||||
vehicleId: 'v-1',
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: 'oil change',
|
||||
description: 'Oil and filter',
|
||||
};
|
||||
|
||||
describe('MaintenanceService auto-next scheduling', () => {
|
||||
it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.SCHEDULED },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 50000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]).toMatchObject({
|
||||
vehicleId: 'v-1',
|
||||
serviceItem: 'oil change',
|
||||
nextDueKm: 60000,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
});
|
||||
|
||||
it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 50000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a km + days interval produces ONE next schedule carrying both thresholds', async () => {
|
||||
const { service, saved } = makeService({
|
||||
before: { ...base, status: MaintenanceStatus.IN_PROGRESS },
|
||||
after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 },
|
||||
interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 },
|
||||
});
|
||||
|
||||
await service.updateMaintenanceSchedule('s-1', {
|
||||
status: MaintenanceStatus.COMPLETED,
|
||||
odometerReading: 20000,
|
||||
});
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0].nextDueKm).toBe(30000);
|
||||
expect(saved[0].nextDueDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { MaintenanceInterval } from './entities/maintenance-interval.entity';
|
||||
import { MaintenanceType } from './entities/maintenance-schedule.entity';
|
||||
|
||||
@@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
||||
super(intervalRepository);
|
||||
}
|
||||
|
||||
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
|
||||
/**
|
||||
* Resolve the interval for a completed service. Prefers the exact
|
||||
* (type, serviceItem) match; a completion without an item falls back to the
|
||||
* type's item-less interval only, so "oil" completions never consume the
|
||||
* "tires" interval.
|
||||
*/
|
||||
async getByVehicleAndType(
|
||||
vehicleId: string,
|
||||
maintenanceType: MaintenanceType,
|
||||
serviceItem?: string | null,
|
||||
): Promise<MaintenanceInterval | null> {
|
||||
return this.intervalRepository.findOne({
|
||||
where: { vehicleId, maintenanceType, isActive: true },
|
||||
where: {
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
isActive: true,
|
||||
serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
|
||||
return this.intervalRepository.find({
|
||||
where: { vehicleId, isActive: true },
|
||||
order: { maintenanceType: 'ASC' },
|
||||
order: { maintenanceType: 'ASC', serviceItem: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async upsertInterval(
|
||||
vehicleId: string,
|
||||
maintenanceType: MaintenanceType,
|
||||
serviceItem?: string | null,
|
||||
intervalKm?: number | null,
|
||||
intervalDays?: number | null,
|
||||
description?: string | null,
|
||||
): Promise<MaintenanceInterval> {
|
||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType);
|
||||
const item = serviceItem?.trim() || null;
|
||||
const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item);
|
||||
|
||||
if (existing) {
|
||||
await this.intervalRepository.update(existing.id, {
|
||||
@@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
||||
this.intervalRepository.create({
|
||||
vehicleId,
|
||||
maintenanceType,
|
||||
serviceItem: item,
|
||||
intervalKm,
|
||||
intervalDays,
|
||||
description,
|
||||
@@ -57,4 +75,9 @@ export class MaintenanceIntervalRepository extends BaseRepository<MaintenanceInt
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Soft-disable: history keeps pointing at it, auto-scheduling stops. */
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.intervalRepository.update(id, { isActive: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { MaintenanceDepthService } from './maintenance-depth.service';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import {
|
||||
CreateWorkOrderDto,
|
||||
UpdateWorkOrderDto,
|
||||
@@ -51,6 +56,27 @@ export class MaintenanceController {
|
||||
return this.maintenanceService.getDueBoard();
|
||||
}
|
||||
|
||||
@Post('intervals')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.create)
|
||||
@ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' })
|
||||
async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.maintenanceService.upsertInterval(dto);
|
||||
}
|
||||
|
||||
@Get('intervals/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: "A vehicle's active service intervals" })
|
||||
async getIntervals(@Param('vehicleId') vehicleId: string) {
|
||||
return this.maintenanceService.getIntervals(vehicleId);
|
||||
}
|
||||
|
||||
@Delete('intervals/:id')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.delete)
|
||||
@ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' })
|
||||
async deactivateInterval(@Param('id') id: string) {
|
||||
return this.maintenanceService.deactivateInterval(id);
|
||||
}
|
||||
|
||||
@Get('upcoming/:vehicleId')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||
|
||||
@@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: Date;
|
||||
nextDueDate: Date | null;
|
||||
@@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
overdue: boolean;
|
||||
}>
|
||||
> {
|
||||
// Every SCHEDULED item, not one per vehicle — a truck legitimately holds
|
||||
// several (oil vs tires intervals differ).
|
||||
return this.scheduleRepository.manager.query(`
|
||||
SELECT DISTINCT ON (s.vehicle_id)
|
||||
SELECT
|
||||
s.id AS "scheduleId",
|
||||
s.vehicle_id AS "vehicleId",
|
||||
v.plate_number AS "plateNumber",
|
||||
s.maintenance_type AS "maintenanceType",
|
||||
s.service_item AS "serviceItem",
|
||||
s.description,
|
||||
s.scheduled_date AS "scheduledDate",
|
||||
s.next_due_date AS "nextDueDate",
|
||||
|
||||
@@ -8,7 +8,12 @@ import { MaintenanceIntervalRepository } from './maintenance-interval.repository
|
||||
import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } 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 {
|
||||
CreateMaintenanceScheduleDto,
|
||||
CreateMaintenanceCostDto,
|
||||
UpdateMaintenanceScheduleDto,
|
||||
UpsertMaintenanceIntervalDto,
|
||||
} from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -107,6 +112,10 @@ export class MaintenanceService {
|
||||
id: string,
|
||||
dto: UpdateMaintenanceScheduleDto,
|
||||
): Promise<MaintenanceSchedule> {
|
||||
// Status BEFORE the write: completing an already-COMPLETED schedule again
|
||||
// must not auto-create a second "next" schedule.
|
||||
const before = await this.scheduleRepository.findOneBy({ id });
|
||||
|
||||
await this.scheduleRepository.update(id, {
|
||||
...dto,
|
||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||
@@ -122,8 +131,12 @@ export class MaintenanceService {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
|
||||
// If completed, schedule the next maintenance based on interval
|
||||
if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) {
|
||||
// First transition into COMPLETED with an odometer → auto-schedule next.
|
||||
if (
|
||||
dto.status === MaintenanceStatus.COMPLETED &&
|
||||
before?.status !== MaintenanceStatus.COMPLETED &&
|
||||
updated.odometerReading != null
|
||||
) {
|
||||
await this.scheduleNextMaintenance(updated);
|
||||
}
|
||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||
@@ -135,52 +148,75 @@ export class MaintenanceService {
|
||||
return updated!;
|
||||
}
|
||||
|
||||
/** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */
|
||||
async upsertInterval(dto: UpsertMaintenanceIntervalDto) {
|
||||
return this.intervalRepository.upsertInterval(
|
||||
dto.vehicleId,
|
||||
dto.maintenanceType,
|
||||
dto.serviceItem ?? null,
|
||||
dto.intervalKm ?? null,
|
||||
dto.intervalDays ?? null,
|
||||
dto.description ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
async getIntervals(vehicleId: string) {
|
||||
return this.intervalRepository.getActiveIntervals(vehicleId);
|
||||
}
|
||||
|
||||
async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> {
|
||||
await this.intervalRepository.deactivate(id);
|
||||
return { id, deactivated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-schedule the next service after a completion: matched on the
|
||||
* completed schedule's (type, serviceItem) interval; one SCHEDULED row
|
||||
* carrying BOTH thresholds when the interval defines km and days —
|
||||
* whichever is crossed first makes it due.
|
||||
*/
|
||||
private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise<void> {
|
||||
try {
|
||||
// Get maintenance interval for this type
|
||||
const interval = await this.intervalRepository.getByVehicleAndType(
|
||||
completed.vehicleId,
|
||||
completed.maintenanceType as MaintenanceType,
|
||||
completed.serviceItem,
|
||||
);
|
||||
|
||||
if (!interval) return; // No interval defined, skip auto-scheduling
|
||||
|
||||
const now = new Date();
|
||||
const completedKm = Number(completed.odometerReading ?? 0);
|
||||
const intervalKm = Number(interval.intervalKm ?? 0);
|
||||
const intervalDays = Number(interval.intervalDays ?? 0);
|
||||
if (intervalKm <= 0 && intervalDays <= 0) return;
|
||||
|
||||
// Calculate next due based on KM interval
|
||||
if (interval.intervalKm && interval.intervalKm > 0) {
|
||||
const nextDueKm = completedKm + Number(interval.intervalKm);
|
||||
const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined;
|
||||
const nextDueDate =
|
||||
intervalDays > 0
|
||||
? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000)
|
||||
: undefined;
|
||||
|
||||
// Create next scheduled maintenance
|
||||
const nextSchedule = this.scheduleRepository.create({
|
||||
const label = interval.serviceItem ? `${interval.serviceItem}: ` : '';
|
||||
const due = [
|
||||
nextDueKm != null ? `${nextDueKm} km` : null,
|
||||
nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
await this.scheduleRepository.save(
|
||||
this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`,
|
||||
serviceItem: completed.serviceItem ?? interval.serviceItem ?? null,
|
||||
description: `${label}${interval.description || completed.description} (next due: ${due})`,
|
||||
scheduledDate: now,
|
||||
nextDueKm,
|
||||
nextDueDate,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
await this.scheduleRepository.save(nextSchedule);
|
||||
}
|
||||
|
||||
// Calculate next due based on date interval
|
||||
if (interval.intervalDays && interval.intervalDays > 0) {
|
||||
const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
// If no KM-based next maintenance was created, use date-based
|
||||
if (!interval.intervalKm) {
|
||||
const nextSchedule = this.scheduleRepository.create({
|
||||
vehicleId: completed.vehicleId,
|
||||
maintenanceType: completed.maintenanceType,
|
||||
description: completed.description,
|
||||
scheduledDate: now,
|
||||
nextDueDate,
|
||||
status: MaintenanceStatus.SCHEDULED,
|
||||
});
|
||||
await this.scheduleRepository.save(nextSchedule);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`,
|
||||
|
||||
@@ -2504,27 +2504,37 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
|
||||
if (result.unloadedCount > 0) {
|
||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||
scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
generatedBy: performedBy ?? 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
if (document.status !== 'ACKNOWLEDGED') {
|
||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||
acknowledgedBy: 'Djibouti Port Operator',
|
||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||
// Best-effort: the unload is already committed — a paperwork failure must
|
||||
// not fail the response (it did once: items unloaded, request 500'd, and
|
||||
// the document only appeared after a manual retry days later). The doc
|
||||
// backfills on any retry since already-unloaded items count as unloaded.
|
||||
try {
|
||||
let document = await this.interchangeDocuments.generateFromSchedule({
|
||||
scheduleId,
|
||||
direction: 'EXPORT',
|
||||
handoverLocation: schedule.destinationName ?? 'Djibouti Port',
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
generatedBy: performedBy ?? 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
if (document.status !== 'ACKNOWLEDGED') {
|
||||
document = await this.interchangeDocuments.acknowledge(document.id, {
|
||||
acknowledgedBy: 'Djibouti Port Operator',
|
||||
remarks: 'Auto acknowledged after Djibouti export unloading.',
|
||||
});
|
||||
}
|
||||
result.interchangeDocument = {
|
||||
id: document.id,
|
||||
documentNo: document.documentNo,
|
||||
status: document.status,
|
||||
};
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`,
|
||||
);
|
||||
}
|
||||
result.interchangeDocument = {
|
||||
id: document.id,
|
||||
documentNo: document.documentNo,
|
||||
status: document.status,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user