mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 06:53:38 +00:00
feat: add wagon usage computation and maintenance logging features
- Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
@@ -291,7 +291,7 @@ describe('BookingClearanceService', () => {
|
||||
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
|
||||
const result = await service.uploadReleaseOrder(
|
||||
'b-export',
|
||||
{ fieldname: 'ro' } as Express.Multer.File,
|
||||
[{ fieldname: 'ro' } as Express.Multer.File],
|
||||
dateStr,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDeliveryOrderFileCode,
|
||||
isDraftDeclarationFileCode,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
@@ -29,7 +30,7 @@ import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -813,7 +814,7 @@ export class BookingClearanceService {
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForOperation(bookingId);
|
||||
}
|
||||
@@ -823,7 +824,7 @@ export class BookingClearanceService {
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Booking> {
|
||||
@@ -832,19 +833,12 @@ export class BookingClearanceService {
|
||||
throw new BadRequestException('Delivery Order applies only to import bookings.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselArrivalDate,
|
||||
@@ -880,7 +874,7 @@ export class BookingClearanceService {
|
||||
|
||||
async uploadReleaseOrder(
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
files: Express.Multer.File[],
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
|
||||
@@ -894,7 +888,6 @@ export class BookingClearanceService {
|
||||
'RELEASE_ORDER_SECURED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||
if (!vesselDepartureDate?.trim()) {
|
||||
throw new BadRequestException('Vessel departure date is required');
|
||||
}
|
||||
@@ -902,12 +895,7 @@ export class BookingClearanceService {
|
||||
const minDays = await this.resolveRoMinDays();
|
||||
const leadDays = this.daysUntil(vesselDepartureDate);
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []);
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDeliveryOrderFileCode,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
type ClearanceSecondDuty,
|
||||
@@ -36,7 +37,7 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, persistDeclarationUploads, persistDeliveryOrderUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -1449,7 +1450,7 @@ export class ContractClearanceService {
|
||||
|
||||
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
|
||||
const files = await this.filesService.findByResource(contractId, 'contracts');
|
||||
if (files.some((f) => f.code === 'delivery_order')) {
|
||||
if (files.some((f) => isDeliveryOrderFileCode(f.code))) {
|
||||
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
|
||||
await this.workflowService.markReadyForBooking(contractId);
|
||||
}
|
||||
@@ -1460,7 +1461,7 @@ export class ContractClearanceService {
|
||||
|
||||
async uploadDeliveryOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
files: Express.Multer.File[],
|
||||
userId?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Contract> {
|
||||
@@ -1470,19 +1471,12 @@ export class ContractClearanceService {
|
||||
throw new BadRequestException('Delivery Order applies only to import contracts.');
|
||||
}
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'delivery_order',
|
||||
file,
|
||||
});
|
||||
await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle) {
|
||||
@@ -1520,7 +1514,7 @@ export class ContractClearanceService {
|
||||
|
||||
async uploadReleaseOrder(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
files: Express.Multer.File[],
|
||||
vesselDepartureDate: string,
|
||||
userId?: string,
|
||||
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
|
||||
@@ -1535,7 +1529,6 @@ export class ContractClearanceService {
|
||||
'RELEASE_ORDER_SECURED',
|
||||
);
|
||||
|
||||
if (!file) throw new BadRequestException('No Release Order uploaded');
|
||||
if (!vesselDepartureDate?.trim()) {
|
||||
throw new BadRequestException('Vessel departure date is required');
|
||||
}
|
||||
@@ -1545,12 +1538,7 @@ export class ContractClearanceService {
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: contractId,
|
||||
resource: 'contracts',
|
||||
code: 'release_order',
|
||||
file,
|
||||
});
|
||||
await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []);
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate,
|
||||
|
||||
@@ -956,20 +956,20 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/delivery-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
|
||||
'GL DJ uploads Delivery Order files (import) with vessel arrival + DO collected dates',
|
||||
})
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
||||
return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), {
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
@@ -977,18 +977,18 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
|
||||
@ApiOperation({ summary: 'GL DJ uploads Release Order files + vessel departure date (export)' })
|
||||
uploadReleaseOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
file,
|
||||
files ?? [],
|
||||
vesselDepartureDate,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
draftDeclarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isDraftDeclarationFileCode,
|
||||
isDeliveryOrderFileCode,
|
||||
isReleaseOrderFileCode,
|
||||
deliveryOrderFileLabel,
|
||||
releaseOrderFileLabel,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
@@ -166,6 +170,98 @@ export async function persistTransitPermitUploads(
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one Delivery Order file in the upload batch. */
|
||||
export function assertDeliveryOrderFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No Delivery Order uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `delivery_order_*` codes for multi-file DO uploads. */
|
||||
export function normalizeDeliveryOrderFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `delivery_order_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all Delivery Order files on a resource with a new multi-file batch. */
|
||||
export async function persistDeliveryOrderUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDeliveryOrderFieldNames(files);
|
||||
assertDeliveryOrderFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isDeliveryOrderFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `delivery_order_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one Release Order file in the upload batch. */
|
||||
export function assertReleaseOrderFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No Release Order uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `release_order_*` codes for multi-file RO uploads. */
|
||||
export function normalizeReleaseOrderFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `release_order_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all Release Order files on a resource with a new multi-file batch. */
|
||||
export async function persistReleaseOrderUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeReleaseOrderFieldNames(files);
|
||||
assertReleaseOrderFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isReleaseOrderFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `release_order_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one export transport document in the upload batch. */
|
||||
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
@@ -422,6 +518,22 @@ export function buildWorkflowFiles(
|
||||
});
|
||||
});
|
||||
|
||||
const extraDeliveryOrders = files
|
||||
.filter((f) => f.code && isDeliveryOrderFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraDeliveryOrders.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: deliveryOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
const extraT1 = files
|
||||
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
@@ -440,6 +552,22 @@ export function buildWorkflowFiles(
|
||||
}
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
const extraReleaseOrders = files
|
||||
.filter((f) => f.code && isReleaseOrderFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraReleaseOrders.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: releaseOrderFileLabel(file.code, index),
|
||||
uploadedBy: 'gl_dj',
|
||||
category: 'djibouti',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
const extraExportTransport = files
|
||||
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
Reference in New Issue
Block a user