mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
- 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.
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
|
|
import { ContractBookingService } from './contract-booking.service';
|
|
import { Contract } from './entities/contract.entity';
|
|
|
|
/**
|
|
* Contract auto-completion by quantity cap. Once a GENERAL contract's capped
|
|
* scope is fully consumed (e.g. a split remainder rebooked), the contract moves
|
|
* to CONTRACT_CLOSED even inside its validity window, and further bookings are
|
|
* blocked — including while a booking window is open. Released capacity
|
|
* (cancelled/expired booking) reopens the contract on the next attempt.
|
|
*/
|
|
describe('ContractBookingService — quantity-cap completion', () => {
|
|
function makeService() {
|
|
const contractsRepository = {
|
|
findByIdWithRelations: jest.fn(),
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const service = new ContractBookingService(
|
|
contractsRepository as never,
|
|
{} as never, // bookingsRepository
|
|
{} as never, // bookingPricingService
|
|
{} as never, // consolidationService
|
|
{} as never, // containerTypesService
|
|
{} as never, // ruleEngineService
|
|
{} as never, // milestoneService
|
|
{} as never, // invoiceService
|
|
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
|
{} as never, // dataSource
|
|
{} as never, // trainSchedulingService
|
|
{} as never, // bookingBatchService
|
|
{} as never, // bookingTransitionService
|
|
{} as never, // consolidationApprovalService
|
|
);
|
|
return { service, contractsRepository };
|
|
}
|
|
|
|
type WithPrivate = {
|
|
maybeCompleteContract: (c: Contract) => Promise<void>;
|
|
};
|
|
|
|
const generalContract = (status: string): Contract =>
|
|
({
|
|
id: 'c-1',
|
|
reference: 'CTR-1',
|
|
contractKind: 'GENERAL',
|
|
status,
|
|
}) as Contract;
|
|
|
|
it('closes a GENERAL contract when every capped line is exhausted', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
|
|
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
|
|
{ containerSize: '40FT', cap: 4, booked: 4, remaining: 0 },
|
|
]);
|
|
|
|
await (service as never as WithPrivate).maybeCompleteContract(
|
|
generalContract('CONTRACT_ACTIVE'),
|
|
);
|
|
|
|
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
|
status: 'CONTRACT_CLOSED',
|
|
});
|
|
});
|
|
|
|
it('absorbs bulk-ton float dust when judging exhaustion', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
jest
|
|
.spyOn(service, 'computeCapacity')
|
|
.mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]);
|
|
|
|
await (service as never as WithPrivate).maybeCompleteContract(
|
|
generalContract('FULLY_EXECUTED'),
|
|
);
|
|
|
|
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
|
status: 'CONTRACT_CLOSED',
|
|
});
|
|
});
|
|
|
|
it('keeps the contract open while any capped line has capacity left', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
jest.spyOn(service, 'computeCapacity').mockResolvedValue([
|
|
{ containerSize: '20FT', cap: 10, booked: 10, remaining: 0 },
|
|
{ containerSize: '40FT', cap: 4, booked: 3, remaining: 1 },
|
|
]);
|
|
|
|
await (service as never as WithPrivate).maybeCompleteContract(
|
|
generalContract('CONTRACT_ACTIVE'),
|
|
);
|
|
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never closes an uncapped contract', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
jest.spyOn(service, 'computeCapacity').mockResolvedValue([]);
|
|
|
|
await (service as never as WithPrivate).maybeCompleteContract(
|
|
generalContract('CONTRACT_ACTIVE'),
|
|
);
|
|
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
const spy = jest.spyOn(service, 'computeCapacity');
|
|
|
|
await (service as never as WithPrivate).maybeCompleteContract({
|
|
id: 'c-1',
|
|
contractKind: 'ONE_TIME',
|
|
status: 'FULLY_EXECUTED',
|
|
} as Contract);
|
|
|
|
expect(spy).not.toHaveBeenCalled();
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a new booking on a completed contract even inside an open window', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
|
generalContract('CONTRACT_CLOSED'),
|
|
);
|
|
jest
|
|
.spyOn(service, 'computeCapacity')
|
|
.mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]);
|
|
|
|
await expect(
|
|
service.createUnderContract('c-1', {} as never, null, null),
|
|
).rejects.toThrow(BadRequestException);
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('completion on booking delivery', () => {
|
|
function makeDeliveryService(contract: Partial<Contract>) {
|
|
const contractsRepository = {
|
|
findById: jest.fn().mockResolvedValue(contract),
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const bookingsRepository = {
|
|
findById: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'b-1', reference: 'BKG-1', contractId: 'c-1' }),
|
|
};
|
|
const service = new ContractBookingService(
|
|
contractsRepository as never,
|
|
bookingsRepository as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ createdToStaff: jest.fn() } as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never, // consolidationApprovalService
|
|
);
|
|
return { service, contractsRepository };
|
|
}
|
|
|
|
it('completes a ONE_TIME contract when its booking is delivered', async () => {
|
|
const { service, contractsRepository } = makeDeliveryService({
|
|
id: 'c-1',
|
|
reference: 'CTR-1',
|
|
contractKind: 'ONE_TIME',
|
|
status: 'CONTRACT_ACTIVE',
|
|
});
|
|
jest.spyOn(service, 'splitOutstanding').mockResolvedValue(null);
|
|
|
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
|
|
|
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
|
status: 'CONTRACT_CLOSED',
|
|
});
|
|
});
|
|
|
|
it('keeps a split ONE_TIME contract open while a remainder is outstanding', async () => {
|
|
const { service, contractsRepository } = makeDeliveryService({
|
|
id: 'c-1',
|
|
reference: 'CTR-1',
|
|
contractKind: 'ONE_TIME',
|
|
freightType: 'CONTAINER',
|
|
status: 'CONTRACT_ACTIVE',
|
|
});
|
|
jest.spyOn(service, 'splitOutstanding').mockResolvedValue({
|
|
bySize: new Map([['20ft', { total: 5, outstanding: 2 }]]),
|
|
bulk: null,
|
|
});
|
|
|
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
|
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves a GENERAL contract alone — it closes on cap or expiry', async () => {
|
|
const { service, contractsRepository } = makeDeliveryService({
|
|
id: 'c-1',
|
|
contractKind: 'GENERAL',
|
|
status: 'CONTRACT_ACTIVE',
|
|
});
|
|
|
|
await service.onBookingCompleted({ bookingId: 'b-1' });
|
|
|
|
expect(contractsRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('reopens a completed contract when capacity was released', async () => {
|
|
const { service, contractsRepository } = makeService();
|
|
contractsRepository.findByIdWithRelations.mockResolvedValue(
|
|
generalContract('CONTRACT_CLOSED'),
|
|
);
|
|
jest
|
|
.spyOn(service, 'computeCapacity')
|
|
.mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]);
|
|
|
|
// The create path continues past the gate and dies later on the bare mocks —
|
|
// only the reopen transition is under test here.
|
|
await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined);
|
|
|
|
expect(contractsRepository.update).toHaveBeenCalledWith('c-1', {
|
|
status: 'CONTRACT_ACTIVE',
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
});
|