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:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),
);

View File

@@ -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 ?? ''));