mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
This commit is contained in:
@@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The customer's shipment request is the order: GL may not change its container
|
||||
* sizes/quantities or billing currency at completion — only per-unit details.
|
||||
*/
|
||||
describe('ContractBookingService — shipment-request lock at completion', () => {
|
||||
type WithAssert = {
|
||||
assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: {
|
||||
paymentCurrency?: string;
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
bulkLines?: Array<{ cargoWeightTons?: number }>;
|
||||
},
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
const serviceWithRequest = (request: unknown): WithAssert => {
|
||||
const svc = Object.create(ContractBookingService.prototype) as WithAssert & {
|
||||
dataSource: unknown;
|
||||
};
|
||||
svc.dataSource = {
|
||||
getRepository: () => ({ findOne: async () => request }),
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
const request = {
|
||||
paymentCurrency: 'USD',
|
||||
requestedLines: {
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('accepts the exact requested quantities and currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects changed quantities', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'USD',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 4 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a changed billing currency', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(request).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [
|
||||
{ containerSize: '20ft', quantity: 2 },
|
||||
{ containerSize: '40ft', quantity: 1 },
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('is a no-op without a linked request', async () => {
|
||||
await expect(
|
||||
serviceWithRequest(null).assertMatchesShipmentRequest('b1', {
|
||||
paymentCurrency: 'ETB',
|
||||
containers: [{ containerSize: '20ft', quantity: 9 }],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import {
|
||||
@@ -395,6 +396,10 @@ export class ContractBookingService {
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
// A rebooked cancellation credit carries `skipAutoConsolidation`: its
|
||||
// shared-wagon partner is picked by GL in the rebook flow, so nothing may
|
||||
// auto-claim (or park) it here behind GL's back.
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(
|
||||
withContainers,
|
||||
))
|
||||
@@ -863,6 +868,12 @@ export class ContractBookingService {
|
||||
direction: contract.tradeDirection ?? null,
|
||||
});
|
||||
|
||||
// The customer's shipment request is the order: sizes, quantities and
|
||||
// billing currency are theirs — GL enters everything else. Both halves of a
|
||||
// consolidated pair pass through here, so each is checked against its OWN
|
||||
// request.
|
||||
await this.assertMatchesShipmentRequest(booking.id, dto);
|
||||
|
||||
const freightType = contract.freightType;
|
||||
let hasCargo =
|
||||
(booking.bookingContainers?.length ?? 0) > 0 ||
|
||||
@@ -1052,6 +1063,72 @@ export class ContractBookingService {
|
||||
return { booking: completed, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* The linked shipment request (customs Path B) is the customer's order:
|
||||
* container sizes + quantities and the billing currency are the customer's
|
||||
* choices, and GL may not change them at completion — only per-unit details
|
||||
* (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a
|
||||
* legacy request without lines/currency ⇒ nothing to enforce. Container lines
|
||||
* are checked only when the payload restates cargo (a day-only resubmit keeps
|
||||
* the already-validated persisted cargo).
|
||||
*/
|
||||
private async assertMatchesShipmentRequest(
|
||||
bookingId: string,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const request = await this.dataSource.getRepository(BookingRequest).findOne({
|
||||
where: { createdBookingId: bookingId },
|
||||
});
|
||||
if (!request) return;
|
||||
const lines = request.requestedLines ?? {};
|
||||
|
||||
if (request.paymentCurrency) {
|
||||
if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) {
|
||||
throw new BadRequestException(
|
||||
`The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`,
|
||||
);
|
||||
}
|
||||
dto.paymentCurrency = request.paymentCurrency;
|
||||
}
|
||||
|
||||
if (dto.containers?.length && lines.containers?.length) {
|
||||
// Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ).
|
||||
const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => {
|
||||
const map = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
const ft = parseInt(String(row.containerSize), 10);
|
||||
map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0));
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const requested = byFt(lines.containers);
|
||||
const given = byFt(dto.containers);
|
||||
const same =
|
||||
requested.size === given.size &&
|
||||
[...requested].every(([ft, qty]) => given.get(ft) === qty);
|
||||
if (!same) {
|
||||
const summary = [...requested]
|
||||
.map(([ft, qty]) => `${qty} × ${ft}ft`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) {
|
||||
const givenTons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
if (givenTons !== Number(lines.bulk.cargoWeightTons)) {
|
||||
throw new BadRequestException(
|
||||
`The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||||
|
||||
Reference in New Issue
Block a user