fix(auth): resolve position-type permissions so GL staff can open clearance pages

This commit is contained in:
Marshal
2026-08-06 13:24:42 +00:00
parent 5933795116
commit 3a2f1a46d6
14 changed files with 466 additions and 11 deletions

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -692,11 +692,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already