mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
Merge pull request #417 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
|
|||||||
|
|
||||||
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */
|
||||||
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin);
|
||||||
|
|
||||||
|
/** Container allocation on a booking (allocate-containers endpoint). */
|
||||||
|
export const AllocationManage = () =>
|
||||||
|
BookingStaff(FREIGHT_PERMS.allocation.manage);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
import { AllocateContainersDto } from './dto/allocate-containers.dto';
|
||||||
|
import { AllocationManage } from '../../common/booking-guards';
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags('bookings')
|
||||||
@Controller('bookings')
|
@Controller('bookings')
|
||||||
@@ -10,6 +11,7 @@ export class BookingAllocationController {
|
|||||||
constructor(private readonly bookingsService: BookingsService) {}
|
constructor(private readonly bookingsService: BookingsService) {}
|
||||||
|
|
||||||
@Post(':bookingId/allocate-containers')
|
@Post(':bookingId/allocate-containers')
|
||||||
|
@AllocationManage()
|
||||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
||||||
async allocateContainers(
|
async allocateContainers(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
ratesService as never,
|
ratesService as never,
|
||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
|
|
||||||
|
export interface OverweightLine {
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComputedPriceResult {
|
export interface ComputedPriceResult {
|
||||||
lineItems: PriceLineItemDto[];
|
lineItems: PriceLineItemDto[];
|
||||||
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
|
|||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
hardBlocked: string[];
|
hardBlocked: string[];
|
||||||
|
overweightLines: OverweightLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredPricingBreakdown = {
|
type StoredPricingBreakdown = {
|
||||||
@@ -67,6 +76,7 @@ export class BookingPricingService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly ratesService: RatesService,
|
private readonly ratesService: RatesService,
|
||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -94,12 +104,19 @@ export class BookingPricingService {
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
|
||||||
|
// (and the overweight warning + surcharge) at the confirm step, before submit.
|
||||||
|
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
|
||||||
|
const pairing = await this.containerValidationService.validate20ftPairing(booking);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
warnings: computed.warnings,
|
warnings: computed.warnings,
|
||||||
|
overweightLines: computed.overweightLines,
|
||||||
|
pairingErrors: pairing.map((p) => p.message),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +186,35 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Overweight detail for the customer: map the engine's per-line results back
|
||||||
|
// to the booking's container lines (same order) for code + weights. maxAllowed
|
||||||
|
// is derived from the line total minus the excess the engine computed.
|
||||||
|
const overweightLines: OverweightLine[] = [];
|
||||||
|
const containerLines = (booking.bookingContainers ?? []).filter(
|
||||||
|
(bc) => bc.containerTypeId != null,
|
||||||
|
);
|
||||||
|
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||||
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
|
if (!wr?.isOverweight) continue;
|
||||||
|
const line = containerLines[i];
|
||||||
|
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
|
||||||
|
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||||
|
let code = line?.containerSize ?? '';
|
||||||
|
if (line?.containerTypeId) {
|
||||||
|
try {
|
||||||
|
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
|
||||||
|
} catch {
|
||||||
|
// fall back to the container size label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overweightLines.push({
|
||||||
|
containerTypeCode: code,
|
||||||
|
totalVgmTons,
|
||||||
|
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
|
||||||
|
excessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
@@ -178,6 +224,7 @@ export class BookingPricingService {
|
|||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
warnings: ruleResult.warnings,
|
warnings: ruleResult.warnings,
|
||||||
hardBlocked: ruleResult.hardBlocked,
|
hardBlocked: ruleResult.hardBlocked,
|
||||||
|
overweightLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, ruleEngineService };
|
return { service, bookingsRepository, ruleEngineService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository };
|
return { service, bookingsRepository };
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, filesService };
|
return { service, bookingsRepository, filesService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
{ isPhasedGeneralCustomsBooking: () => false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
);
|
);
|
||||||
return { service, bookingsRepository, bookingBatchService };
|
return { service, bookingsRepository, bookingBatchService };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
|
|||||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { clearanceCodesForBooking } from './clearance.util';
|
import { clearanceCodesForBooking } from './clearance.util';
|
||||||
@@ -51,6 +52,7 @@ export class BookingTransitionService {
|
|||||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -58,6 +60,19 @@ export class BookingTransitionService {
|
|||||||
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||||
|
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||||
|
const violations =
|
||||||
|
await this.containerValidationService.validate20ftPairing(booking);
|
||||||
|
if (violations.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
|
||||||
|
.map((v) => v.message)
|
||||||
|
.join(' ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||||
@@ -78,6 +93,11 @@ export class BookingTransitionService {
|
|||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
|
||||||
|
// If no balanced pairing exists the booking cannot proceed (overweight only
|
||||||
|
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
|
||||||
|
await this.assert20ftPairable(booking);
|
||||||
|
|
||||||
const stored = booking.pricingBreakdown as {
|
const stored = booking.pricingBreakdown as {
|
||||||
lineItems?: PriceLineItemDto[];
|
lineItems?: PriceLineItemDto[];
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
@@ -158,6 +178,7 @@ export class BookingTransitionService {
|
|||||||
hardBlocked: computed.hardBlocked,
|
hardBlocked: computed.hardBlocked,
|
||||||
requiresDirectorApproval: false,
|
requiresDirectorApproval: false,
|
||||||
});
|
});
|
||||||
|
await this.assert20ftPairable(booking);
|
||||||
|
|
||||||
await this.pricingService.createPricingSnapshots(
|
await this.pricingService.createPricingSnapshots(
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -993,12 +1014,14 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
||||||
// booking's day still has capacity — nothing below runs and the request stays
|
// booking's day still has capacity — nothing below runs and the request stays
|
||||||
// pending for staff to move/decline.
|
// pending for staff to move/decline. (For a consolidated pair this is a rough
|
||||||
|
// solo pre-check; the real combined-capacity reservation happens after the
|
||||||
|
// booking is FULLY_EXECUTED, once both partners are ready.)
|
||||||
const isExportTrain =
|
const isExportTrain =
|
||||||
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
||||||
const exportScheduleId = isExportTrain
|
if (isExportTrain) {
|
||||||
? await this.bookingBatchService.pickExportSchedule(booking)
|
await this.bookingBatchService.pickExportSchedule(booking);
|
||||||
: null;
|
}
|
||||||
|
|
||||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1023,11 +1046,12 @@ export class BookingTransitionService {
|
|||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (exportScheduleId) {
|
if (isExportTrain) {
|
||||||
// FCFS: reserve the slot and send the payment notification immediately;
|
// FCFS: reserve the slot and send the payment notification immediately;
|
||||||
// paid → auto-allocated by the settle/paid pipeline.
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||||
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||||
const fresh = await this.bookingsService.findById(booking.id);
|
const fresh = await this.bookingsService.findById(booking.id);
|
||||||
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
|
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||||
} else if (booking.tradeDirection === "IMPORT") {
|
} else if (booking.tradeDirection === "IMPORT") {
|
||||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||||
// after staff document review, never at accept time.
|
// after staff document review, never at accept time.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
|
|||||||
// import { PayController } from './pay.controller';
|
// import { PayController } from './pay.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
|
import { ContainerValidationService } from './container-validation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
@@ -79,6 +80,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
|
ContainerValidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides).
|
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||||
|
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
|
||||||
|
*
|
||||||
|
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
|
||||||
|
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
|
||||||
|
* the same train is useless. The day filter is applied only when THIS booking already has
|
||||||
|
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
|
||||||
*/
|
*/
|
||||||
async findComplementaryConsolidationPartner(
|
async findComplementaryConsolidationPartner(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
): Promise<Booking | null> {
|
): Promise<Booking | null> {
|
||||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||||
|
|
||||||
return this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
.innerJoin('bc.containerType', 'ct')
|
.innerJoin('bc.containerType', 'ct')
|
||||||
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||||
quantity,
|
quantity,
|
||||||
perWagon,
|
perWagon,
|
||||||
})
|
});
|
||||||
.orderBy('b.createdAt', 'ASC')
|
|
||||||
.getOne();
|
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
|
||||||
|
// when this booking has no date yet (matched again once it picks its day).
|
||||||
|
if (booking.scheduledDate) {
|
||||||
|
qb.andWhere(
|
||||||
|
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||||
|
{ bookingDate: booking.scheduledDate },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { validate20ftWeightPairing } from './container-pairing.util';
|
||||||
|
|
||||||
|
describe('validate20ftWeightPairing', () => {
|
||||||
|
const MAX_DIFF = 10;
|
||||||
|
|
||||||
|
it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
|
||||||
|
// sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 24 },
|
||||||
|
{ label: 'B', grossWeightTons: 8 },
|
||||||
|
{ label: 'C', grossWeightTons: 18 },
|
||||||
|
{ label: 'D', grossWeightTons: 15 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a pair whose weight difference exceeds the cap', () => {
|
||||||
|
// sorted: 5, 25 → single pair diff 20 > 10.
|
||||||
|
const units = [
|
||||||
|
{ label: 'HEAVY', grossWeightTons: 25 },
|
||||||
|
{ label: 'LIGHT', grossWeightTons: 5 },
|
||||||
|
];
|
||||||
|
const result = validate20ftWeightPairing(units, MAX_DIFF);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
|
||||||
|
expect(result[0].diffTons).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
|
||||||
|
// sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 10 },
|
||||||
|
{ label: 'B', grossWeightTons: 12 },
|
||||||
|
{ label: 'C', grossWeightTons: 30 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
|
||||||
|
// Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
|
||||||
|
// but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
|
||||||
|
const units = [
|
||||||
|
{ label: 'A', grossWeightTons: 20 },
|
||||||
|
{ label: 'B', grossWeightTons: 12 },
|
||||||
|
{ label: 'C', grossWeightTons: 22 },
|
||||||
|
{ label: 'D', grossWeightTons: 10 },
|
||||||
|
];
|
||||||
|
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for fewer than two units', () => {
|
||||||
|
expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
|
||||||
|
expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Booking-time 20ft weight-pairing rule.
|
||||||
|
*
|
||||||
|
* A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
|
||||||
|
* same wagon their gross-weight difference must not exceed `maxPairDiffTons`
|
||||||
|
* (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
|
||||||
|
* balanced. 40ft containers occupy a whole wagon alone and never pair.
|
||||||
|
*
|
||||||
|
* At booking time the customer enters every 20ft container's weight but not its
|
||||||
|
* wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
|
||||||
|
* (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
|
||||||
|
* valid pairing exists this one finds it — a violation here means no balanced
|
||||||
|
* pairing is possible and the booking must be blocked. An odd leftover 20ft is
|
||||||
|
* fine: it has no partner in this booking and flows to consolidation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Container20ftUnit {
|
||||||
|
/** Human label for messages, e.g. the container number. */
|
||||||
|
label: string;
|
||||||
|
grossWeightTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PairingViolation {
|
||||||
|
message: string;
|
||||||
|
/** The two container labels whose pairing exceeds the diff cap. */
|
||||||
|
labels: [string, string];
|
||||||
|
diffTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the given 20ft units can all be paired onto wagons within the
|
||||||
|
* weight-difference cap. Returns one violation per over-cap adjacent pair (empty
|
||||||
|
* when every wagon pair is balanced or there is nothing to pair). A single
|
||||||
|
* leftover unit (odd count) is not a violation.
|
||||||
|
*/
|
||||||
|
export function validate20ftWeightPairing(
|
||||||
|
units: Container20ftUnit[],
|
||||||
|
maxPairDiffTons: number,
|
||||||
|
): PairingViolation[] {
|
||||||
|
if (units.length < 2 || maxPairDiffTons == null) return [];
|
||||||
|
|
||||||
|
// Ascending by weight: adjacent pairs have the smallest possible diffs.
|
||||||
|
const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
|
||||||
|
const violations: PairingViolation[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i + 1 < sorted.length; i += 2) {
|
||||||
|
const a = sorted[i];
|
||||||
|
const b = sorted[i + 1];
|
||||||
|
const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
|
||||||
|
if (diff > maxPairDiffTons) {
|
||||||
|
violations.push({
|
||||||
|
message:
|
||||||
|
`20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
|
||||||
|
`${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
|
||||||
|
`weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
|
||||||
|
labels: [a.label, b.label],
|
||||||
|
diffTons: round2(diff),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||||
|
import {
|
||||||
|
Container20ftUnit,
|
||||||
|
PairingViolation,
|
||||||
|
validate20ftWeightPairing,
|
||||||
|
} from './container-pairing.util';
|
||||||
|
|
||||||
|
/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
|
||||||
|
const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-time container validations that need the customer-entered per-unit
|
||||||
|
* weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
|
||||||
|
* rule engine (which works on line totals) because pairing is per physical unit.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerValidationService {
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
private async maxPairDiffTons(): Promise<number> {
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(TrainSchedulingGlobalRules)
|
||||||
|
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
const v = row?.max20ftPairWeightDiffTons;
|
||||||
|
const n = v == null ? NaN : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
|
||||||
|
private async load20ftUnits(booking: Booking): Promise<Container20ftUnit[]> {
|
||||||
|
const lines = (booking.bookingContainers ?? []).filter(
|
||||||
|
(bc) => (bc.containerSize ?? '').includes('20'),
|
||||||
|
);
|
||||||
|
if (!lines.length) return [];
|
||||||
|
|
||||||
|
const units = await this.dataSource
|
||||||
|
.getRepository(BookingContainerUnit)
|
||||||
|
.find({
|
||||||
|
where: { bookingContainerId: In(lines.map((l) => l.id)) },
|
||||||
|
order: { sortOrder: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return units.map((u) => ({
|
||||||
|
label: u.containerNumber || u.id.slice(0, 8),
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the 20ft weight-pairing rule for a booking. Returns one message per
|
||||||
|
* pair whose weight difference exceeds the cap; empty when all 20ft can be
|
||||||
|
* balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
|
||||||
|
* it flows to consolidation. Callers hard-block a non-empty result.
|
||||||
|
*/
|
||||||
|
async validate20ftPairing(booking: Booking): Promise<PairingViolation[]> {
|
||||||
|
// Only bookings whose 20ft lines actually carry per-unit weights can be
|
||||||
|
// checked; contract-drawdown bookings do (units are required there).
|
||||||
|
const containerLines = booking.bookingContainers ?? [];
|
||||||
|
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||||
|
if (!has20ft) return [];
|
||||||
|
|
||||||
|
const units = await this.load20ftUnits(booking);
|
||||||
|
if (units.length < 2) return [];
|
||||||
|
|
||||||
|
const maxDiff = await this.maxPairDiffTons();
|
||||||
|
return validate20ftWeightPairing(units, maxDiff);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,20 @@ export class PriceLineItemDto {
|
|||||||
currency!: string;
|
currency!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OverweightLineDto {
|
||||||
|
@ApiProperty()
|
||||||
|
containerTypeCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalVgmTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
maxAllowedTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
excessTons!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class GeneratePriceResponseDto {
|
export class GeneratePriceResponseDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
bookingId!: string;
|
bookingId!: string;
|
||||||
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [String] })
|
||||||
warnings!: string[];
|
warnings!: string[];
|
||||||
|
|
||||||
|
/** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
|
||||||
|
@ApiProperty({ type: [OverweightLineDto] })
|
||||||
|
overweightLines!: OverweightLineDto[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 20ft weight-pairing violations. Non-empty means the booking cannot be
|
||||||
|
* balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
|
||||||
|
* container weights/quantities. (Overweight, by contrast, only warns.)
|
||||||
|
*/
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
pairingErrors!: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -563,6 +565,115 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-create validation for the shipment form: run the overweight rule + the
|
||||||
|
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
||||||
|
* booking. The portal calls this from the price-confirm modal so the customer
|
||||||
|
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
||||||
|
* un-pairable 20ft set before the booking is created.
|
||||||
|
*/
|
||||||
|
async validateShipment(
|
||||||
|
contractId: string,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<{
|
||||||
|
overweightLines: Array<{
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}>;
|
||||||
|
pairingErrors: string[];
|
||||||
|
}> {
|
||||||
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
|
const lines = dto.containers ?? [];
|
||||||
|
if (!lines.length) return { overweightLines: [], pairingErrors: [] };
|
||||||
|
|
||||||
|
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
||||||
|
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
lines.map(async (line) => {
|
||||||
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
|
line.containerSize,
|
||||||
|
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const totalVgmTons = (line.units ?? []).reduce(
|
||||||
|
(s, u) => s + Number(u.vgmTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { line, ct, totalVgmTons };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate({
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: null,
|
||||||
|
serviceTypeId: contract.serviceTypeId,
|
||||||
|
paymentCurrency: contract.paymentCurrency,
|
||||||
|
tradeDirection: contract.tradeDirection,
|
||||||
|
isHazardous: false,
|
||||||
|
isReefer: contract.isReefer ?? false,
|
||||||
|
isGovernment: false,
|
||||||
|
allowConsolidation: false,
|
||||||
|
shippingLineId: null,
|
||||||
|
totalWagons: 0,
|
||||||
|
bulkTons: 0,
|
||||||
|
containers: resolved.map((r) => ({
|
||||||
|
containerTypeId: r.ct.id,
|
||||||
|
quantity: r.line.quantity,
|
||||||
|
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
||||||
|
totalVgmTons: r.totalVgmTons,
|
||||||
|
isReefer: r.ct.isReefer,
|
||||||
|
})),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const overweightLines: Array<{
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}> = [];
|
||||||
|
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||||
|
const wr = ruleResult.containerWeightResults[i];
|
||||||
|
if (!wr?.isOverweight) continue;
|
||||||
|
const r = resolved[i];
|
||||||
|
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||||
|
overweightLines.push({
|
||||||
|
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
|
||||||
|
totalVgmTons: r?.totalVgmTons ?? 0,
|
||||||
|
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
|
||||||
|
excessTons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||||
|
const twentyFtUnits = resolved
|
||||||
|
.filter((r) => (r.line.containerSize ?? '').includes('20'))
|
||||||
|
.flatMap((r) =>
|
||||||
|
(r.line.units ?? []).map((u, idx) => ({
|
||||||
|
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const maxDiff = await this.max20ftPairDiffTons();
|
||||||
|
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
|
||||||
|
(v) => v.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { overweightLines, pairingErrors };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async max20ftPairDiffTons(): Promise<number> {
|
||||||
|
const row = await this.dataSource
|
||||||
|
.getRepository(TrainSchedulingGlobalRules)
|
||||||
|
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||||||
|
.then((rows) => rows[0] ?? null)
|
||||||
|
.catch(() => null);
|
||||||
|
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
|
||||||
|
return Number.isFinite(n) ? n : 10;
|
||||||
|
}
|
||||||
|
|
||||||
/** Pick the default container type for a size; prefer reefer when requested. */
|
/** Pick the default container type for a size; prefer reefer when requested. */
|
||||||
private async resolveContainerTypeForSize(
|
private async resolveContainerTypeForSize(
|
||||||
size: string,
|
size: string,
|
||||||
|
|||||||
@@ -788,6 +788,18 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/validate-shipment')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||||
|
})
|
||||||
|
validateShipment(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CreateBookingUnderContractDto,
|
||||||
|
) {
|
||||||
|
return this.contractBookingService.validateShipment(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/capacity')
|
@Get(':id/capacity')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
||||||
|
|||||||
@@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reserves both partners of a consolidated pair together on one train', async () => {
|
||||||
|
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
|
||||||
|
const consol = (id: string, partnerId: string, priority: number): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: priority,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: partnerId,
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||||
|
consol('a', 'b', 30),
|
||||||
|
consol('b', 'a', 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Both reserved on the same (first) train; neither reported unplaced.
|
||||||
|
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||||
|
expect(reservedIds.sort()).toEqual(['a', 'b']);
|
||||||
|
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
|
||||||
|
const lonely = {
|
||||||
|
id: 'a',
|
||||||
|
reference: 'a',
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 30,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: 'missing-partner',
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Never reserved — waits for its partner in a later cycle.
|
||||||
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
interface Capacity {
|
||||||
@@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
/** Set when this booking shares a wagon with a consolidation partner. */
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
@@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* fits the booking. Throws ConflictException when every train is full — the
|
* fits the booking. Throws ConflictException when every train is full — the
|
||||||
* staff accept fails and no more export bookings are taken.
|
* staff accept fails and no more export bookings are taken.
|
||||||
*/
|
*/
|
||||||
async pickExportSchedule(booking: Booking): Promise<string> {
|
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||||
if (!booking.scheduledDate) {
|
if (!booking.scheduledDate) {
|
||||||
throw new BadRequestException('Booking has no scheduled date');
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
}
|
}
|
||||||
@@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const required = need ?? this.needFor(booking, wagonLengths);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
@@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
if (this.fits(need, budget)) return schedule.id;
|
if (this.fits(required, budget)) return schedule.id;
|
||||||
}
|
}
|
||||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve an accepted export booking on its picked train and open the pay
|
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||||
* window immediately (payment notification goes out on reserve). Marks the
|
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||||
* train FULL when this reservation exhausts the wagon budget.
|
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
|
||||||
|
* against the combined shared-wagon need; the first partner's accept just waits.
|
||||||
|
* Throws ConflictException (before this booking is persisted-ready) when there is
|
||||||
|
* no export capacity for the day, so staff accept fails.
|
||||||
*/
|
*/
|
||||||
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
|
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||||
await this.reserve(booking, scheduleId);
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking);
|
||||||
|
await this.reserveOnExport([booking], scheduleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
|
||||||
|
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
|
||||||
|
// waits; the partner's later accept will reserve the pair.
|
||||||
|
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||||
|
await this.reserveOnExport([booking, partner], scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
|
||||||
|
private async reserveOnExport(
|
||||||
|
bookings: Booking[],
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||||
this.armSettle(scheduleId);
|
this.armSettle(scheduleId);
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
@@ -622,6 +655,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve consolidation-partner references for the shared-wagon badge. Most
|
||||||
|
// partners are on this same schedule; look up any that aren't in one query.
|
||||||
|
const refById = new Map(
|
||||||
|
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
|
||||||
|
);
|
||||||
|
const missingPartnerIds = [
|
||||||
|
...new Set(
|
||||||
|
bookings
|
||||||
|
.map((b) => b.consolidationPartnerId)
|
||||||
|
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (missingPartnerIds.length) {
|
||||||
|
const partners = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.find({ where: { id: In(missingPartnerIds) } });
|
||||||
|
for (const p of partners) {
|
||||||
|
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||||
const need = this.needFor(b, wagonLengths);
|
const need = this.needFor(b, wagonLengths);
|
||||||
const alloc = allocationByBooking.get(b.id);
|
const alloc = allocationByBooking.get(b.id);
|
||||||
@@ -647,6 +701,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null,
|
: null,
|
||||||
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
||||||
allocationIssue: alloc?.issue ?? null,
|
allocationIssue: alloc?.issue ?? null,
|
||||||
|
consolidationPartnerId: b.consolidationPartnerId ?? null,
|
||||||
|
consolidationPartnerRef: b.consolidationPartnerId
|
||||||
|
? (refById.get(b.consolidationPartnerId) ?? null)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -903,13 +961,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
if (!this.fits(need, budget)) {
|
if (!this.fits(need, budget)) {
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
budget = await this.preemptForGovernment(
|
budget = await this.preemptForGovernment(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
need,
|
need,
|
||||||
@@ -918,14 +982,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(scheduleId, booking, "gov");
|
await this.allocate(scheduleId, booking, "gov");
|
||||||
|
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, scheduleId);
|
await this.reserve(booking, scheduleId);
|
||||||
|
if (partner) await this.reserve(partner, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget = this.subtract(budget, need);
|
||||||
@@ -1013,15 +1079,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
destinationYardId,
|
destinationYardId,
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
|
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||||
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
// First train (earliest departure) that fits this booking as-is.
|
// First train (earliest departure) that fits this unit as-is.
|
||||||
let target = trains.find((t) => this.fits(need, t.budget));
|
let target = trains.find((t) => this.fits(need, t.budget));
|
||||||
|
|
||||||
if (!target && booking.isGovernment) {
|
if (!target && isGov) {
|
||||||
// Government booking fits nowhere on its own — try to preempt commercial
|
// Government fits nowhere on its own — try to preempt commercial
|
||||||
// on each train (earliest first) until one frees enough room.
|
// on each train (earliest first) until one frees enough room.
|
||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
t.budget = await this.preemptForGovernment(
|
t.budget = await this.preemptForGovernment(
|
||||||
@@ -1038,41 +1112,45 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
// A consolidated pair is placed whole or not at all — never split.
|
||||||
// partial-capacity offer on the train with the most free wagons: pay =
|
if (!isPair) {
|
||||||
// accept the split (remainder returns to the contract cap), no pay =
|
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||||
// booking stays whole and expires for this train.
|
// partial-capacity offer on the train with the most free wagons.
|
||||||
const partialTarget = [...trains]
|
const partialTarget = [...trains]
|
||||||
.filter((t) => t.budget.wagons >= 1)
|
.filter((t) => t.budget.wagons >= 1)
|
||||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||||
if (
|
if (
|
||||||
partialTarget &&
|
partialTarget &&
|
||||||
!booking.isGovernment &&
|
!booking.isGovernment &&
|
||||||
booking.tradeDirection === "IMPORT" &&
|
booking.tradeDirection === "IMPORT" &&
|
||||||
booking.contractKind === "GENERAL" &&
|
booking.contractKind === "GENERAL" &&
|
||||||
this.splitService
|
this.splitService
|
||||||
) {
|
) {
|
||||||
const offered = await this.tryPartialOffer(
|
const offered = await this.tryPartialOffer(
|
||||||
booking,
|
booking,
|
||||||
partialTarget.id,
|
partialTarget.id,
|
||||||
partialTarget.budget,
|
partialTarget.budget,
|
||||||
need,
|
need,
|
||||||
);
|
);
|
||||||
if (offered) {
|
if (offered) {
|
||||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||||
partialTarget.armed = true;
|
partialTarget.armed = true;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stays in the pool, retried next batch/window cycle.
|
// Stays in the pool, retried next batch/window cycle.
|
||||||
this.notifier.unplaced(booking, day);
|
this.notifier.unplaced(booking, day);
|
||||||
|
if (partner) this.notifier.unplaced(partner, day);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(target.id, booking, "gov");
|
await this.allocate(target.id, booking, "gov");
|
||||||
|
if (partner) await this.allocate(target.id, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, target.id);
|
await this.reserve(booking, target.id);
|
||||||
|
if (partner) await this.reserve(partner, target.id);
|
||||||
target.armed = true;
|
target.armed = true;
|
||||||
}
|
}
|
||||||
target.budget = this.subtract(target.budget, need);
|
target.budget = this.subtract(target.budget, need);
|
||||||
@@ -1099,6 +1177,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
need: Capacity,
|
need: Capacity,
|
||||||
): Promise<Capacity | null> {
|
): Promise<Capacity | null> {
|
||||||
if (!this.splitService) return null;
|
if (!this.splitService) return null;
|
||||||
|
// A consolidated booking is already half of a shared wagon — never split it.
|
||||||
|
if (booking.consolidationPartnerId) return null;
|
||||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||||
|
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
@@ -1143,29 +1223,69 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return capacity > 0 ? capacity : 60;
|
return capacity > 0 ? capacity : 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/**
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||||
|
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
||||||
|
* path: expire it). Consolidated pairs settle atomically: both allocate only
|
||||||
|
* when both paid; if either partner expires, both expire (a half-paid shared
|
||||||
|
* wagon must not ship). Returns whether anything changed.
|
||||||
|
*/
|
||||||
|
private async settleReserved(
|
||||||
|
scheduleId: string,
|
||||||
|
expireUnpaidUnknownDeadline: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
const reserved =
|
const reserved =
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||||
|
const done = new Set<string>();
|
||||||
let anySettled = false;
|
let anySettled = false;
|
||||||
|
|
||||||
for (const booking of reserved) {
|
const isPaid = (b: Booking) =>
|
||||||
const paid =
|
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
const isExpired = (b: Booking) =>
|
||||||
const expired = booking.paymentDeadline
|
b.paymentDeadline
|
||||||
? booking.paymentDeadline.getTime() <= now
|
? b.paymentDeadline.getTime() <= now
|
||||||
: false;
|
: expireUnpaidUnknownDeadline;
|
||||||
|
|
||||||
if (paid) {
|
for (const booking of reserved) {
|
||||||
|
if (done.has(booking.id)) continue;
|
||||||
|
const partner = booking.consolidationPartnerId
|
||||||
|
? (byId.get(booking.consolidationPartnerId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (partner) {
|
||||||
|
done.add(booking.id);
|
||||||
|
done.add(partner.id);
|
||||||
|
// Both-or-neither: allocate the shared wagon only when both partners paid;
|
||||||
|
// if either lapsed, expire both so no half-paid wagon rides.
|
||||||
|
if (isPaid(booking) && isPaid(partner)) {
|
||||||
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
|
await this.allocate(scheduleId, partner, "paid");
|
||||||
|
anySettled = true;
|
||||||
|
} else if (isExpired(booking) || isExpired(partner)) {
|
||||||
|
await this.expire(booking);
|
||||||
|
await this.expire(partner);
|
||||||
|
anySettled = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
done.add(booking.id);
|
||||||
|
if (isPaid(booking)) {
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
} else if (expired) {
|
} else if (isExpired(booking)) {
|
||||||
await this.expire(booking);
|
await this.expire(booking);
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return anySettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
|
const anySettled = await this.settleReserved(scheduleId, false);
|
||||||
if (anySettled) await this.fillSchedule(scheduleId);
|
if (anySettled) await this.fillSchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,25 +1294,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/** Allocate paid reservations, expire the rest, then top up. */
|
/** Allocate paid reservations, expire the rest, then top up. */
|
||||||
async settleBatch(scheduleId: string): Promise<void> {
|
async settleBatch(scheduleId: string): Promise<void> {
|
||||||
this.removeTimeout(scheduleId);
|
this.removeTimeout(scheduleId);
|
||||||
const reserved =
|
await this.settleReserved(scheduleId, true);
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const booking of reserved) {
|
|
||||||
const paid =
|
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
|
||||||
const expired = booking.paymentDeadline
|
|
||||||
? booking.paymentDeadline.getTime() <= now
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (paid) {
|
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
|
||||||
} else if (expired) {
|
|
||||||
await this.expire(booking);
|
|
||||||
}
|
|
||||||
// else: still within window (rare at settle) → leave for the re-armed timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.fillSchedule(scheduleId);
|
await this.fillSchedule(scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
@@ -1452,6 +1554,74 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- capacity helpers -----------------------------------------------------
|
// ---- capacity helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse consolidated partners into single pool entries so the fill treats a
|
||||||
|
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
|
||||||
|
* - no `consolidationPartnerId` → passes through as a lone booking.
|
||||||
|
* - consolidated + partner also in this pool → emitted ONCE (at the position of
|
||||||
|
* whichever partner ranks first) as a pair; the partner is not emitted again.
|
||||||
|
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
|
||||||
|
* it waits for the partner to become ready in a later cycle).
|
||||||
|
* The pool is already priority-ordered, so emitting the pair at the first-seen
|
||||||
|
* partner's slot ranks it by the stronger (max-priority) partner automatically.
|
||||||
|
*/
|
||||||
|
private groupConsolidatedPool(
|
||||||
|
pool: Booking[],
|
||||||
|
): Array<{ primary: Booking; partner: Booking | null }> {
|
||||||
|
const byId = new Map(pool.map((b) => [b.id, b]));
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
|
||||||
|
for (const booking of pool) {
|
||||||
|
if (emitted.has(booking.id)) continue;
|
||||||
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
emitted.add(booking.id);
|
||||||
|
units.push({ primary: booking, partner: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const partner = byId.get(partnerId) ?? null;
|
||||||
|
if (!partner) {
|
||||||
|
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
|
||||||
|
emitted.add(booking.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emitted.add(booking.id);
|
||||||
|
emitted.add(partner.id);
|
||||||
|
units.push({ primary: booking, partner });
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
|
||||||
|
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
|
||||||
|
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
|
||||||
|
* independently-rounded-up needs (that is the capacity consolidation saves).
|
||||||
|
*/
|
||||||
|
private combinedNeed(
|
||||||
|
primary: Booking,
|
||||||
|
partner: Booking,
|
||||||
|
wagonLengths: WagonLengths,
|
||||||
|
): Capacity {
|
||||||
|
const containers = (b: Booking): number =>
|
||||||
|
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
|
const totalContainers = containers(primary) + containers(partner);
|
||||||
|
const sharedWagons =
|
||||||
|
totalContainers > 0
|
||||||
|
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||||
|
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||||
|
const weightTons =
|
||||||
|
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||||
|
return {
|
||||||
|
wagons: sharedWagons,
|
||||||
|
weightTons,
|
||||||
|
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||||
|
container: wagonLengths.container,
|
||||||
|
bulk: wagonLengths.bulk,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private wagonsFor(booking: Booking): number {
|
private wagonsFor(booking: Booking): number {
|
||||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||||
return Math.ceil(booking.wagonsRequired);
|
return Math.ceil(booking.wagonsRequired);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BOOKING_RULE_ENGINE_PERMISSIONS,
|
BOOKING_RULE_ENGINE_PERMISSIONS,
|
||||||
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
BOOKING_RULE_ENGINE_PERMISSION_KEYS,
|
||||||
|
POSITION_PERMISSION_PRESETS,
|
||||||
ROLE_PERMISSION_PRESETS,
|
ROLE_PERMISSION_PRESETS,
|
||||||
} from './freight-permissions.registry';
|
} from './freight-permissions.registry';
|
||||||
|
|
||||||
@@ -10,6 +11,13 @@ export type FreightSeedRole = {
|
|||||||
permissionKeys: string[];
|
permissionKeys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FreightSeedPosition = {
|
||||||
|
key: string;
|
||||||
|
name: { en: string };
|
||||||
|
rank: number;
|
||||||
|
permissionKeys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
const IAM_PERMISSION_KEYS = {
|
const IAM_PERMISSION_KEYS = {
|
||||||
activateEmployee: "can:activateEmployee",
|
activateEmployee: "can:activateEmployee",
|
||||||
activateUser: "can:activateUser",
|
activateUser: "can:activateUser",
|
||||||
@@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
|||||||
permissionKeys: [],
|
permissionKeys: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operational positions (positions-as-roles). Seeded as Position +
|
||||||
|
* PositionPermission rows (NOT Role/RolePermission). Users get their access by
|
||||||
|
* being assigned to a Position via EmployeePosition.
|
||||||
|
*/
|
||||||
|
export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
|
||||||
|
{ key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] },
|
||||||
|
{ key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] },
|
||||||
|
{ key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] },
|
||||||
|
{ key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] },
|
||||||
|
{ key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] },
|
||||||
|
{ key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] },
|
||||||
|
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
|
||||||
|
];
|
||||||
|
|||||||
@@ -3,14 +3,28 @@ import {
|
|||||||
Organization,
|
Organization,
|
||||||
OrganizationConfiguration,
|
OrganizationConfiguration,
|
||||||
Permission,
|
Permission,
|
||||||
|
Position,
|
||||||
|
PositionPermission,
|
||||||
|
PositionType,
|
||||||
Role,
|
Role,
|
||||||
RolePermission,
|
RolePermission,
|
||||||
|
Unit,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource, EntityManager, In } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum";
|
||||||
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry";
|
||||||
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
import {
|
||||||
|
EDR_FREIGHT_POSITIONS,
|
||||||
|
EDR_FREIGHT_ROLES,
|
||||||
|
type FreightSeedPosition,
|
||||||
|
type FreightSeedRole,
|
||||||
|
} from "./edr-freight.seed";
|
||||||
|
|
||||||
|
const EDR_UNIT_KEY = "edr_freight_hq";
|
||||||
|
const EDR_UNIT_NAME = { en: "EDR Freight HQ" };
|
||||||
|
const EDR_POSITION_TYPE_KEY = "edr_freight_role";
|
||||||
|
const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" };
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||||
@@ -40,6 +54,19 @@ export class EdrOrgSeeder {
|
|||||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
||||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
||||||
await this.ensureSuperAdminPermissions(manager);
|
await this.ensureSuperAdminPermissions(manager);
|
||||||
|
|
||||||
|
// Positions-as-roles: seed operational positions and grant their
|
||||||
|
// permissions via PositionPermission (not Role/RolePermission).
|
||||||
|
const unit = await this.ensureDefaultUnit(manager, organization.id);
|
||||||
|
const positionType = await this.ensureDefaultPositionType(manager, unit.id);
|
||||||
|
await this.ensurePositions(
|
||||||
|
manager,
|
||||||
|
organization.id,
|
||||||
|
unit.id,
|
||||||
|
positionType.id,
|
||||||
|
EDR_FREIGHT_POSITIONS,
|
||||||
|
);
|
||||||
|
await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||||
@@ -205,4 +232,146 @@ export class EdrOrgSeeder {
|
|||||||
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
`Ensured ${permissions.length} booking+rule-engine permissions on super_admin`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultUnit(
|
||||||
|
manager: EntityManager,
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const unitRepository = manager.getRepository(Unit);
|
||||||
|
|
||||||
|
let unit = await unitRepository.findOne({
|
||||||
|
where: { key: EDR_UNIT_KEY, organizationId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
const insertResult = await unitRepository.insert({
|
||||||
|
key: EDR_UNIT_KEY,
|
||||||
|
name: EDR_UNIT_NAME,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`);
|
||||||
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`);
|
||||||
|
return { id: unit.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultPositionType(
|
||||||
|
manager: EntityManager,
|
||||||
|
unitId: string,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const positionTypeRepository = manager.getRepository(PositionType);
|
||||||
|
|
||||||
|
// PositionType has no unique constraint on (key, unitId); find-then-insert.
|
||||||
|
let positionType = await positionTypeRepository.findOne({
|
||||||
|
where: { key: EDR_POSITION_TYPE_KEY, unitId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!positionType) {
|
||||||
|
const insertResult = await positionTypeRepository.insert({
|
||||||
|
key: EDR_POSITION_TYPE_KEY,
|
||||||
|
name: EDR_POSITION_TYPE_NAME,
|
||||||
|
isSystem: true,
|
||||||
|
unitId,
|
||||||
|
});
|
||||||
|
this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
||||||
|
return { id: insertResult.identifiers[0]?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`);
|
||||||
|
return { id: positionType.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePositions(
|
||||||
|
manager: EntityManager,
|
||||||
|
organizationId: string,
|
||||||
|
unitId: string,
|
||||||
|
positionTypeId: string,
|
||||||
|
seedPositions: FreightSeedPosition[],
|
||||||
|
) {
|
||||||
|
await manager.getRepository(Position).upsert(
|
||||||
|
seedPositions.map(({ key, name, rank }) => ({
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
rank,
|
||||||
|
organizationId,
|
||||||
|
unitId,
|
||||||
|
positionTypeId,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
conflictPaths: { key: true, unitId: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${seedPositions.length} EDR positions '${seedPositions
|
||||||
|
.map((position) => position.key)
|
||||||
|
.join("', '")}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensurePositionPermissions(
|
||||||
|
manager: EntityManager,
|
||||||
|
unitId: string,
|
||||||
|
seedPositions: FreightSeedPosition[],
|
||||||
|
) {
|
||||||
|
const permissionKeys = [
|
||||||
|
...new Set(seedPositions.flatMap((position) => position.permissionKeys)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!permissionKeys.length) {
|
||||||
|
this.logger.log(
|
||||||
|
"No EDR position permissions configured; skipping position-permission links",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = await manager.getRepository(Position).find({
|
||||||
|
where: { key: In(seedPositions.map((position) => position.key)), unitId },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
const seededPermissions = await manager.getRepository(Permission).find({
|
||||||
|
where: { key: In(permissionKeys) },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const positionByKey = new Map(
|
||||||
|
positions.map((position) => [position.key, position]),
|
||||||
|
);
|
||||||
|
const permissionByKey = new Map(
|
||||||
|
seededPermissions.map((permission) => [permission.key, permission]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const positionPermissions = seedPositions.flatMap((position) => {
|
||||||
|
const seededPosition = positionByKey.get(position.key);
|
||||||
|
|
||||||
|
if (!seededPosition) {
|
||||||
|
throw new Error(`missing_position:${position.key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return position.permissionKeys.map((permissionKey) => {
|
||||||
|
const seededPermission = permissionByKey.get(permissionKey);
|
||||||
|
|
||||||
|
if (!seededPermission) {
|
||||||
|
throw new Error(`missing_permission:${permissionKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionId: seededPosition.id as string,
|
||||||
|
permissionId: seededPermission.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.getRepository(PositionPermission).upsert(positionPermissions, {
|
||||||
|
conflictPaths: { positionId: true, permissionId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Ensured ${positionPermissions.length} EDR position-permission links`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container-allocation permission for the previously-unguarded
|
||||||
|
* booking allocate-containers endpoint.
|
||||||
|
*/
|
||||||
|
export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
|
perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'),
|
||||||
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
export const BOOKING_RULE_ENGINE_PERMISSIONS = [
|
||||||
...BOOKING_PERMISSIONS,
|
...BOOKING_PERMISSIONS,
|
||||||
...CONTRACT_PERMISSIONS,
|
...CONTRACT_PERMISSIONS,
|
||||||
...RULE_ENGINE_PERMISSIONS,
|
...RULE_ENGINE_PERMISSIONS,
|
||||||
|
...GAP_CONTROLLER_PERMISSIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map(
|
||||||
@@ -171,6 +180,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: (slug: RuleEngineResourceSlug) =>
|
manage: (slug: RuleEngineResourceSlug) =>
|
||||||
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
`edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`,
|
||||||
},
|
},
|
||||||
|
allocation: {
|
||||||
|
manage: 'edr_freight_app:allocation:manage',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const allRuleEngineViewKeys = () =>
|
const allRuleEngineViewKeys = () =>
|
||||||
@@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position permission presets (positions-as-roles). Grants flow to users via
|
||||||
|
* Position → PositionPermission (NOT Role/RolePermission). Each reuses the
|
||||||
|
* matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the
|
||||||
|
* gap-controller keys the position needs. Deduped via Set.
|
||||||
|
*/
|
||||||
|
const dedupe = (keys: string[]): string[] => [...new Set(keys)];
|
||||||
|
|
||||||
|
export const POSITION_PERMISSION_PRESETS = {
|
||||||
|
// Chief: senior operational role — intake/line-staff approval + director
|
||||||
|
// approval + scheduling/ops, plus container allocation.
|
||||||
|
chief: dedupe([
|
||||||
|
...ROLE_PERMISSION_PRESETS.lineStaff,
|
||||||
|
...ROLE_PERMISSION_PRESETS.director,
|
||||||
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
|
FREIGHT_PERMS.allocation.manage,
|
||||||
|
]),
|
||||||
|
director: dedupe([...ROLE_PERMISSION_PRESETS.director]),
|
||||||
|
ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]),
|
||||||
|
ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]),
|
||||||
|
djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]),
|
||||||
|
marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]),
|
||||||
|
operation: dedupe([
|
||||||
|
...ROLE_PERMISSION_PRESETS.operationsOfficer,
|
||||||
|
FREIGHT_PERMS.allocation.manage,
|
||||||
|
]),
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({
|
||||||
key: p.key,
|
key: p.key,
|
||||||
label: p.name.en,
|
label: p.name.en,
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon';
|
|||||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||||
import {
|
import {
|
||||||
Employee,
|
Employee,
|
||||||
|
EmployeePosition,
|
||||||
Organization,
|
Organization,
|
||||||
|
Position,
|
||||||
Role,
|
Role,
|
||||||
|
Unit,
|
||||||
User,
|
User,
|
||||||
UserCredential,
|
UserCredential,
|
||||||
UserRole,
|
UserRole,
|
||||||
@@ -13,13 +16,19 @@ import { DataSource } from 'typeorm';
|
|||||||
|
|
||||||
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
const SEED_FLAG = 'SEED_FREIGHT_STAFF';
|
||||||
const EDR_ORG_KEY = 'edr_freight';
|
const EDR_ORG_KEY = 'edr_freight';
|
||||||
|
const EDR_UNIT_KEY = 'edr_freight_hq';
|
||||||
|
|
||||||
|
// roleKey is kept only for backwards compatibility with existing UserRole rows;
|
||||||
|
// access is granted via the assigned position (positionKey) + PositionPermission.
|
||||||
const STAFF_USERS = [
|
const STAFF_USERS = [
|
||||||
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' },
|
{ email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' },
|
||||||
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director' },
|
{ email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' },
|
||||||
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' },
|
{ email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' },
|
||||||
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' },
|
{ email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' },
|
||||||
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' },
|
{ email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' },
|
||||||
|
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
|
||||||
|
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
|
||||||
|
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder {
|
|||||||
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
throw new Error(`missing_organization:${EDR_ORG_KEY}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unit = await manager.getRepository(Unit).findOne({
|
||||||
|
where: { key: EDR_UNIT_KEY, organizationId: organization.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unit) {
|
||||||
|
throw new Error(`missing_unit:${EDR_UNIT_KEY}`);
|
||||||
|
}
|
||||||
|
|
||||||
const roleRepository = manager.getRepository(Role);
|
const roleRepository = manager.getRepository(Role);
|
||||||
const userRepository = manager.getRepository(User);
|
const userRepository = manager.getRepository(User);
|
||||||
const userCredentialRepository = manager.getRepository(UserCredential);
|
const userCredentialRepository = manager.getRepository(UserCredential);
|
||||||
const userRoleRepository = manager.getRepository(UserRole);
|
const userRoleRepository = manager.getRepository(UserRole);
|
||||||
const employeeRepository = manager.getRepository(Employee);
|
const employeeRepository = manager.getRepository(Employee);
|
||||||
|
const positionRepository = manager.getRepository(Position);
|
||||||
|
const employeePositionRepository = manager.getRepository(EmployeePosition);
|
||||||
|
|
||||||
const hashedPassword = await hashPassword(password);
|
const hashedPassword = await hashPassword(password);
|
||||||
|
|
||||||
@@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder {
|
|||||||
{ conflictPaths: { userId: true, roleId: true } },
|
{ conflictPaths: { userId: true, roleId: true } },
|
||||||
);
|
);
|
||||||
|
|
||||||
const employeeExists = await employeeRepository.exists({
|
let employee = await employeeRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!employeeExists) {
|
if (!employee) {
|
||||||
await employeeRepository.insert({
|
employee = await employeeRepository.save(
|
||||||
userId: user.id,
|
employeeRepository.create({
|
||||||
organizationId: organization.id,
|
userId: user.id,
|
||||||
|
organizationId: organization.id,
|
||||||
|
unitId: unit.id,
|
||||||
|
isCurrent: true,
|
||||||
|
name: { en: staff.username },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant access via the assigned position (positions-as-roles).
|
||||||
|
const position = await positionRepository.findOne({
|
||||||
|
where: { key: staff.positionKey, unitId: unit.id },
|
||||||
|
select: { id: true, key: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new Error(`missing_position:${staff.positionKey}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employeePositionExists = await employeePositionRepository.exists({
|
||||||
|
where: {
|
||||||
|
employeeId: employee.id as string,
|
||||||
|
positionId: position.id as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employeePositionExists) {
|
||||||
|
await employeePositionRepository.insert({
|
||||||
|
employeeId: employee.id as string,
|
||||||
|
positionId: position.id as string,
|
||||||
|
unitId: unit.id,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
name: { en: staff.username },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ import { RequirePermission } from "./components/auth/RequirePermission";
|
|||||||
import {
|
import {
|
||||||
FREIGHT_PERMS,
|
FREIGHT_PERMS,
|
||||||
hasPermission as hasFreightPermission,
|
hasPermission as hasFreightPermission,
|
||||||
|
isDjiboutiGl,
|
||||||
|
isEthiopianGl,
|
||||||
|
isSuperAdmin,
|
||||||
} from "./lib/permissions";
|
} from "./lib/permissions";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
@@ -451,12 +454,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Keep only items the user is permitted to see; drop now-empty sections. */
|
/** Hrefs of the two document-clearance menu items (stable identifiers). */
|
||||||
|
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||||
|
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||||
|
|
||||||
|
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
item.href === ET_CLEARANCE_HREF;
|
||||||
|
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
item.href === DJ_CLEARANCE_HREF;
|
||||||
|
const isClearanceItem = (item: SidebarItem): boolean =>
|
||||||
|
isEtClearanceItem(item) || isDjClearanceItem(item);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only items the user is permitted to see; drop now-empty sections.
|
||||||
|
*
|
||||||
|
* Position-scoped visibility (super_admin bypasses all of this):
|
||||||
|
* - Ethiopian GL → sees ONLY the ET document-clearance page.
|
||||||
|
* - Djibouti GL → sees ONLY the DJ clearance page.
|
||||||
|
* - Everyone else → sees everything they have permission for, EXCEPT the two
|
||||||
|
* clearance pages (those are GL-only).
|
||||||
|
*/
|
||||||
const filterSidebarByPermission = (
|
const filterSidebarByPermission = (
|
||||||
sections: SidebarSection[],
|
sections: SidebarSection[],
|
||||||
user: ReturnType<typeof useAuth>["user"],
|
user: ReturnType<typeof useAuth>["user"],
|
||||||
): SidebarSection[] => {
|
): SidebarSection[] => {
|
||||||
const itemAllowed = (item: SidebarItem): boolean => {
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const etGl = !superAdmin && isEthiopianGl(user);
|
||||||
|
const djGl = !superAdmin && isDjiboutiGl(user);
|
||||||
|
|
||||||
|
const permissionAllowed = (item: SidebarItem): boolean => {
|
||||||
if (!item.permission) return true;
|
if (!item.permission) return true;
|
||||||
const keys = Array.isArray(item.permission)
|
const keys = Array.isArray(item.permission)
|
||||||
? item.permission
|
? item.permission
|
||||||
@@ -464,6 +490,19 @@ const filterSidebarByPermission = (
|
|||||||
return keys.some((key) => hasFreightPermission(user, key));
|
return keys.some((key) => hasFreightPermission(user, key));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const itemAllowed = (item: SidebarItem): boolean => {
|
||||||
|
if (superAdmin) return true;
|
||||||
|
|
||||||
|
// GL positions are locked to their single clearance page.
|
||||||
|
if (etGl) return isEtClearanceItem(item);
|
||||||
|
if (djGl) return isDjClearanceItem(item);
|
||||||
|
|
||||||
|
// Everyone else: hide the GL-only clearance pages entirely.
|
||||||
|
if (isClearanceItem(item)) return false;
|
||||||
|
|
||||||
|
return permissionAllowed(item);
|
||||||
|
};
|
||||||
|
|
||||||
return sections
|
return sections
|
||||||
.map((section) => ({
|
.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
@@ -485,6 +524,22 @@ const DashboardShell = () => {
|
|||||||
);
|
);
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
|
// GL positions are locked to their single clearance page: if they navigate
|
||||||
|
// (or deep-link) anywhere else, send them back to their clearance hub.
|
||||||
|
// Super admin is exempt. Allow the clearance path + its detail sub-routes.
|
||||||
|
const superAdmin = isSuperAdmin(user);
|
||||||
|
const glClearanceHome = !superAdmin
|
||||||
|
? isEthiopianGl(user)
|
||||||
|
? ET_CLEARANCE_HREF
|
||||||
|
: isDjiboutiGl(user)
|
||||||
|
? DJ_CLEARANCE_HREF
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||||
|
return <Navigate to={glClearanceHome} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FreightDashboardLayout
|
<FreightDashboardLayout
|
||||||
sidebarSections={sidebarSections}
|
sidebarSections={sidebarSections}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
clearanceReview: "edr_freight_app:contracts:clearance_review",
|
||||||
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
|
||||||
createBooking: "edr_freight_app:contracts:create_booking",
|
createBooking: "edr_freight_app:contracts:create_booking",
|
||||||
|
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
|
||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
@@ -46,6 +47,9 @@ export const FREIGHT_PERMS = {
|
|||||||
manage: "edr_freight_app:fleet:manage",
|
manage: "edr_freight_app:fleet:manage",
|
||||||
},
|
},
|
||||||
admin: "edr_freight_app:admin",
|
admin: "edr_freight_app:admin",
|
||||||
|
allocation: {
|
||||||
|
manage: "edr_freight_app:allocation:manage",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
const slugToResourceKey = (slug: RuleEngineResourceSlug): string =>
|
||||||
@@ -69,6 +73,38 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
|||||||
return [...keys];
|
return [...keys];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||||
|
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||||
|
if (!user) return [];
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const emp of user.employee ?? []) {
|
||||||
|
for (const pos of emp.positions ?? []) {
|
||||||
|
if (pos.key) keys.add(pos.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPosition(
|
||||||
|
user: AuthUser | null | undefined,
|
||||||
|
positionKey: string,
|
||||||
|
): boolean {
|
||||||
|
return getPositionKeys(user).includes(positionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POSITION_KEYS = {
|
||||||
|
ethiopianGl: "ethiopian_gl",
|
||||||
|
djiboutiGl: "djibouti_gl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPosition(user, POSITION_KEYS.ethiopianGl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
|
||||||
|
return hasPosition(user, POSITION_KEYS.djiboutiGl);
|
||||||
|
}
|
||||||
|
|
||||||
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {
|
||||||
if (user?.isSuperAdmin) return true;
|
if (user?.isSuperAdmin) return true;
|
||||||
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
return Boolean(user?.roles?.some((r) => r.key === "super_admin"));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowLeftRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
|||||||
Gov
|
Gov
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{b.consolidationPartnerRef ? (
|
||||||
|
<Tooltip
|
||||||
|
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||||||
|
withArrow
|
||||||
|
multiline
|
||||||
|
maw={260}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ArrowLeftRight size={10} />}
|
||||||
|
style={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
shared wagon · {b.consolidationPartnerRef}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/contracts/${id}/clearance/documents`,
|
`/api/contracts/${id}/clearance/documents`,
|
||||||
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
CLEARANCE_DUTY_SLIP: (id: string) => `/api/contracts/${id}/clearance/duty-slip`,
|
||||||
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
BOOKINGS: (id: string) => `/api/contracts/${id}/bookings`,
|
||||||
|
VALIDATE_SHIPMENT: (id: string) =>
|
||||||
|
`/api/contracts/${id}/validate-shipment`,
|
||||||
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
MILESTONES: (id: string) => `/api/contracts/${id}/milestones`,
|
||||||
BOOKING_MILESTONES: (bookingId: string) =>
|
BOOKING_MILESTONES: (bookingId: string) =>
|
||||||
`/api/contracts/bookings/${bookingId}/milestones`,
|
`/api/contracts/bookings/${bookingId}/milestones`,
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default function InvoiceDetailPage() {
|
|||||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
||||||
"TELEBIRR",
|
"TELEBIRR",
|
||||||
);
|
);
|
||||||
|
console.log(paymentMethod)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: invoice,
|
data: invoice,
|
||||||
@@ -127,7 +128,7 @@ export default function InvoiceDetailPage() {
|
|||||||
|
|
||||||
const payable = isPayable(invoice.status);
|
const payable = isPayable(invoice.status);
|
||||||
const lines = invoice.lines ?? [];
|
const lines = invoice.lines ?? [];
|
||||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
// const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||||
|
|
||||||
const handlePay = () => {
|
const handlePay = () => {
|
||||||
setPayModalOpen(true);
|
setPayModalOpen(true);
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ export function BookingPaymentPanel({
|
|||||||
: priceTotal(pricing);
|
: priceTotal(pricing);
|
||||||
const items = priceLineItems(pricing);
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
|
// Consolidation: this shipment shares a wagon with a partner booking, and the
|
||||||
|
// wagon is only scheduled once both partners have paid. Surface a note while
|
||||||
|
// payment is still pending (pay-window open, or a deadline set and not paid).
|
||||||
|
const showConsolidationNote =
|
||||||
|
!paid &&
|
||||||
|
Boolean(booking.consolidationPartnerId) &&
|
||||||
|
(booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
|
Boolean(booking.paymentDeadline));
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", booking.id],
|
queryKey: ["booking-invoices", booking.id],
|
||||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||||
@@ -268,6 +277,12 @@ export function BookingPaymentPanel({
|
|||||||
onPay={onPay}
|
onPay={onPay}
|
||||||
paying={paying}
|
paying={paying}
|
||||||
/>
|
/>
|
||||||
|
{showConsolidationNote && (
|
||||||
|
<Text mt={12} fz="12px" c="#9AA8B5" lh={1.5}>
|
||||||
|
This shipment shares a wagon with a consolidation partner — both
|
||||||
|
shipments must be paid for the wagon to be scheduled.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { OperationDatePicker } from "@edr/ui-common";
|
import { OperationDatePicker } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { ShipmentValidation } from "@/services/contracts.service";
|
||||||
import {
|
import {
|
||||||
SelectField,
|
SelectField,
|
||||||
StepCard,
|
StepCard,
|
||||||
@@ -149,6 +151,8 @@ function NewShipmentBookingForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isContainerContract = contract.freightType === "CONTAINER";
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
api.contracts.createBookingUnderContract.call({ id: contractId, dto }),
|
||||||
@@ -161,6 +165,14 @@ function NewShipmentBookingForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-submit validation (container contracts only): warns on overweight
|
||||||
|
// containers and HARD-BLOCKS on 20ft wagon-pairing errors. Runs each time the
|
||||||
|
// price modal opens so re-reviewing after an edit re-checks.
|
||||||
|
const validateMutation = useMutation({
|
||||||
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
|
api.contracts.validateShipment.call({ id: contractId, dto }),
|
||||||
|
});
|
||||||
|
|
||||||
function buildDto(
|
function buildDto(
|
||||||
values: ShipmentFormValues,
|
values: ShipmentFormValues,
|
||||||
): Freight.CreateBookingUnderContractDto {
|
): Freight.CreateBookingUnderContractDto {
|
||||||
@@ -207,13 +219,22 @@ function NewShipmentBookingForm({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit validates the whole form, then opens the price modal for confirmation.
|
// Submit validates the whole form, then opens the price modal for
|
||||||
|
// confirmation. For container contracts we also run the server-side shipment
|
||||||
|
// validation (overweight warnings + 20ft pairing hard-blocks) so the modal
|
||||||
|
// can surface them before the booking is created.
|
||||||
const handleReview = form.handleSubmit((values) => {
|
const handleReview = form.handleSubmit((values) => {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
|
if (isContainerContract) {
|
||||||
|
validateMutation.reset();
|
||||||
|
validateMutation.mutate(buildDto(values));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (!pendingValues) return;
|
if (!pendingValues) return;
|
||||||
|
// Guard: never let a booking with unresolved 20ft pairing errors submit.
|
||||||
|
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
|
||||||
submitMutation.mutate(buildDto(pendingValues));
|
submitMutation.mutate(buildDto(pendingValues));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +242,7 @@ function NewShipmentBookingForm({
|
|||||||
const handleReject = () => {
|
const handleReject = () => {
|
||||||
if (submitMutation.isPending) return;
|
if (submitMutation.isPending) return;
|
||||||
setPendingValues(null);
|
setPendingValues(null);
|
||||||
|
validateMutation.reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
@@ -323,6 +345,8 @@ function NewShipmentBookingForm({
|
|||||||
contract={contract}
|
contract={contract}
|
||||||
values={pendingValues}
|
values={pendingValues}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
|
validation={validateMutation.data ?? null}
|
||||||
|
validationLoading={validateMutation.isPending}
|
||||||
onConfirm={handleConfirm}
|
onConfirm={handleConfirm}
|
||||||
onReject={handleReject}
|
onReject={handleReject}
|
||||||
/>
|
/>
|
||||||
@@ -334,12 +358,16 @@ function PriceConfirmModal({
|
|||||||
contract,
|
contract,
|
||||||
values,
|
values,
|
||||||
loading,
|
loading,
|
||||||
|
validation,
|
||||||
|
validationLoading,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onReject,
|
onReject,
|
||||||
}: {
|
}: {
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
values: ShipmentFormValues | null;
|
values: ShipmentFormValues | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
validation: ShipmentValidation | null;
|
||||||
|
validationLoading: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -348,6 +376,11 @@ function PriceConfirmModal({
|
|||||||
[contract, values],
|
[contract, values],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const overweightLines = validation?.overweightLines ?? [];
|
||||||
|
const pairingErrors = validation?.pairingErrors ?? [];
|
||||||
|
const hasPairingBlock = pairingErrors.length > 0;
|
||||||
|
const confirmDisabled = loading || validationLoading || hasPairingBlock;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(values)}
|
opened={Boolean(values)}
|
||||||
@@ -376,6 +409,60 @@ function PriceConfirmModal({
|
|||||||
>
|
>
|
||||||
{total ? (
|
{total ? (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
{validationLoading && (
|
||||||
|
<Group gap={8} c="dimmed">
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Checking container weights and wagon pairing…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPairingBlock && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title="Cannot create booking — 20ft wagon pairing"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{pairingErrors.map((msg, i) => (
|
||||||
|
<Text key={i} fz="sm" c="red.8">
|
||||||
|
{msg}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="red.7" mt={2}>
|
||||||
|
Adjust the 20ft container weights or quantities so pairs differ
|
||||||
|
by no more than 10 tons.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overweightLines.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Overweight containers"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{overweightLines.map((line, i) => (
|
||||||
|
<Text key={i} fz="sm" c="#9A5B00">
|
||||||
|
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||||
|
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||||
|
An overweight surcharge applies. You can still submit, or go
|
||||||
|
back and adjust weights.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
<Paper withBorder radius={16} p="lg" style={{ borderColor: "#E6ECF2" }}>
|
||||||
<Stack gap={10}>
|
<Stack gap={10}>
|
||||||
{total.lines.map((line, i) => (
|
{total.lines.map((line, i) => (
|
||||||
@@ -442,6 +529,7 @@ function PriceConfirmModal({
|
|||||||
leftSection={<CheckCircle2 size={16} />}
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={confirmDisabled}
|
||||||
>
|
>
|
||||||
Confirm & book
|
Confirm & book
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ContractDocuments,
|
ContractDocuments,
|
||||||
GenerateContractPriceResponse,
|
GenerateContractPriceResponse,
|
||||||
SubmitContractResponse,
|
SubmitContractResponse,
|
||||||
|
ShipmentValidation,
|
||||||
} from "./contracts.service";
|
} from "./contracts.service";
|
||||||
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
|
||||||
import {
|
import {
|
||||||
@@ -472,6 +473,13 @@ export const api = {
|
|||||||
contractsService.createBookingUnderContract(id, dto),
|
contractsService.createBookingUnderContract(id, dto),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
validateShipment: endpoint<
|
||||||
|
{ id: string; dto: Freight.CreateBookingUnderContractDto },
|
||||||
|
ShipmentValidation
|
||||||
|
>("contracts", "validateShipment", ({ id, dto }) =>
|
||||||
|
contractsService.validateShipment(id, dto),
|
||||||
|
),
|
||||||
|
|
||||||
getContractMilestones: endpoint<
|
getContractMilestones: endpoint<
|
||||||
{ id: string },
|
{ id: string },
|
||||||
Freight.IClearanceMilestone[]
|
Freight.IClearanceMilestone[]
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ export interface SubmitContractResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A container line whose total VGM exceeds the weight-limit rule. */
|
||||||
|
export interface OverweightLine {
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-submit validation for a shipment booking under a CONTAINER contract.
|
||||||
|
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
|
||||||
|
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
|
||||||
|
* that cannot be balanced onto wagons) and must prevent booking.
|
||||||
|
*/
|
||||||
|
export interface ShipmentValidation {
|
||||||
|
overweightLines: OverweightLine[];
|
||||||
|
pairingErrors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContractListFilter {
|
export interface ContractListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
statuses?: string;
|
statuses?: string;
|
||||||
@@ -285,6 +304,20 @@ export const contractsService = {
|
|||||||
return data.data.booking ?? data.data;
|
return data.data.booking ?? data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-submit validation of a shipment booking (same DTO as
|
||||||
|
* `createBookingUnderContract`). Returns overweight warnings and hard-block
|
||||||
|
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
|
||||||
|
* booking is created.
|
||||||
|
*/
|
||||||
|
validateShipment: async (
|
||||||
|
id: string,
|
||||||
|
dto: Freight.CreateBookingUnderContractDto,
|
||||||
|
): Promise<ShipmentValidation> => {
|
||||||
|
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Milestones ──
|
// ── Milestones ──
|
||||||
getContractMilestones: async (
|
getContractMilestones: async (
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user