feat(permissions): add granular permissions for train management actions

feat(train-builder): update permissions checks for train actions in UI
feat(contracts): enhance contract view and shipment request pages with new features
test(shipment-preview): add tests for customs clearing logic in booking preview
style(contract-sign-bar): create CSS for fixed sign bar layout
This commit is contained in:
marshalyordanos
2026-08-11 11:48:16 +03:00
parent ea6eccbf09
commit 07a120af5e
9 changed files with 267 additions and 73 deletions

View File

@@ -1974,6 +1974,11 @@ export class ContractBookingService {
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
// The clearance fee is gated on this flag in BookingPricingService, and
// createUnderContract copies it off the contract. Omitting it here priced
// the preview WITHOUT the customs line the created booking is then billed
// — the customer confirmed one total and got invoiced a larger one.
customsClearingEnabled: contract.customsClearingEnabled,
shippingLineId: null,
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,

View File

@@ -0,0 +1,123 @@
import { ContractBookingService } from './contract-booking.service';
import type { Contract } from './entities/contract.entity';
import type { Booking } from '../bookings/entities/booking.entity';
import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/**
* The price the customer confirms in the modal comes from validateShipment,
* which prices an UNSAVED twin of the booking createUnderContract will write.
* Any contract field that reaches the pricing service must be copied onto that
* twin — a field left off doesn't fail loudly, it silently drops whole charge
* lines from the quote while the created booking is still billed for them.
*
* The regression this locks: `customsClearingEnabled` was missing, so
* BookingPricingService's `if (booking.customsClearingEnabled)` gate never
* opened in the preview. Container bookings quoted rail freight alone, then
* invoiced rail + customs clearance.
*/
describe('shipment preview / created booking parity', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c1',
freightType: 'CONTAINER',
tradeDirection: 'EXPORT',
paymentCurrency: 'ETB',
serviceTypeId: 'svc1',
customsClearingEnabled: true,
equipmentReturn: 'NO_RETURN',
isHazardous: false,
isReefer: false,
isGovernment: false,
cargoScope: [],
firstMilePickupAddress: null,
lastMileDeliveryAddress: null,
...over,
}) as Contract;
/**
* Run validateShipment against stubbed collaborators and hand back the
* booking the pricing service was actually asked to price.
*/
const previewBookingFor = async (c: Contract): Promise<Booking> => {
let priced: Booking | null = null;
const svc = {
contractsRepository: { findByIdWithRelations: async () => c },
bookingPricingService: {
computePriceForBooking: async (b: Booking) => {
priced = b;
return {
lineItems: [],
totalAmount: 0,
currency: 'ETB',
overweightLines: [],
hardBlocked: [],
};
},
},
ruleEngineService: { capacityViolations: async () => [] },
resolveRoute: async () => null,
resolveShipmentCurrency: () => 'ETB',
resolveCargoTypeId: () => null,
resolveShipmentHandlingFlag: () => false,
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
resolveBulkTons: () => 0,
resolveBulkWeightTons: () => 0,
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
handlingCounts: () => ({
hazardousQuantity: 0,
reeferQuantity: 0,
returnQuantity: 0,
}),
max20ftPairDiffTons: async () => 2,
findContainerClashesOnTrain: async () => [],
};
const dto = {
containers: [
{
containerSize: '40ft',
quantity: 2,
units: [{ vgmTons: 10 }, { vgmTons: 10 }],
},
],
} as unknown as CreateBookingUnderContractDto;
await (
ContractBookingService.prototype as unknown as {
validateShipment: (
this: unknown,
id: string,
dto: CreateBookingUnderContractDto,
) => Promise<unknown>;
}
).validateShipment.call(svc, 'c1', dto);
if (!priced) throw new Error('pricing service was never called');
return priced;
};
it('prices the preview with customs clearing on when the contract clears', async () => {
// Without this the clearance fee is quoted as 0 and billed in full later.
const booking = await previewBookingFor(contract());
expect(booking.customsClearingEnabled).toBe(true);
});
it('leaves customs clearing off when the contract does not clear', async () => {
const booking = await previewBookingFor(
contract({ customsClearingEnabled: false }),
);
expect(booking.customsClearingEnabled).toBe(false);
});
it('carries the contract mile legs so trucking is quoted too', async () => {
const booking = await previewBookingFor(
contract({
firstMilePickupAddress: 'Modjo Dry Port',
lastMileDeliveryAddress: 'Djibouti Port',
}),
);
expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port');
expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port');
});
});

View File

@@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.update,
FREIGHT_PERMS.trains.assignWagons,
FREIGHT_PERMS.trains.delete,
FREIGHT_PERMS.trains.changeLocomotives,
FREIGHT_PERMS.trains.changeYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@@ -72,7 +76,7 @@ export class TrainBuilderController {
}
@Put(':id/locomotives')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
setLocomotives(
@Param('id', ParseUUIDPipe) id: string,
@@ -94,7 +98,7 @@ export class TrainBuilderController {
}
@Patch(':id/yard')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.changeYard)
@ApiOperation({
summary: 'Relocate the train — its locomotives and wagons move to the new yard with it',
})
@@ -143,7 +147,7 @@ export class TrainBuilderController {
}
@Post(':id/deactivate')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
@ApiOperation({
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
})
@@ -152,14 +156,14 @@ export class TrainBuilderController {
}
@Post(':id/activate')
@FleetManage(FREIGHT_PERMS.trains.update)
@FleetManage(FREIGHT_PERMS.trains.toggleActive)
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
activate(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.activate(id);
}
@Delete(':id')
@FleetManage(FREIGHT_PERMS.trains.delete)
@FleetManage(FREIGHT_PERMS.trains.disband)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' })
disband(@Param('id', ParseUUIDPipe) id: string) {