diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 58f82856e..60eebf3da 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -848,8 +848,10 @@ export class ContractClearanceService { } /** - * GL ET clearance hub: every customs (Path B) contract in phased clearance, - * including after booking is created. + * GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in + * phased clearance that already carry at least one uploaded clearance + * document — a contract still waiting for its first document has nothing to + * review, and GENERAL contracts clear per booking, not at contract level. */ async queue(filter: FilterContractDto): Promise { return this.contractsRepository.findAllPaginated({ @@ -857,6 +859,8 @@ export class ContractClearanceService { pageSize: filter.pageSize ?? 100, statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], customsClearingEnabled: true, + contractKind: 'ONE_TIME', + hasClearanceDocuments: true, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index be6316ade..3c9c7db14 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -26,6 +26,8 @@ export interface ContractListFilterOptions { tradeDirection?: string; paymentCurrency?: string; customsClearingEnabled?: boolean; + /** true → only contracts with at least one uploaded clearance document. */ + hasClearanceDocuments?: boolean; createdFrom?: string; createdTo?: string; } @@ -284,6 +286,12 @@ export class ContractsRepository extends BaseRepository { customsClearingEnabled: options.customsClearingEnabled, }); } + if (options.hasClearanceDocuments) { + qb.andWhere( + 'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' + + 'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)', + ); + } if (options.serviceTypeId) { qb.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId: options.serviceTypeId, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index c0aa14bd9..9e82bca33 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1136,14 +1136,35 @@ export class TrainSchedulingService { throw new BadRequestException('Schedule has no train set'); } - // Batch parity: a schedule may only allocate bookings that targeted it. This mirrors - // the automatic fill, which only pulls bookings whose train_schedule_id is this schedule. + // Batch parity: a schedule may only allocate bookings from its route-day POOL. + // Under day-level pooling (see fillRouteDayInternal) an unreserved booking has + // a NULL train_schedule_id and is only pinned by reserve(); a reserved one is + // pinned to whichever train in the day's group first held it. Every train + // sharing this origin + destination + EAT departure day draws from ONE shared + // pool (one shared booking window), so a booking is allocatable here when it is + // either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking + // pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/ + // day/capacity fit is enforced downstream by validateBookingsForScheduling. + // EXPORT never groups, so its pool is this schedule alone (plus NULL pool). if (dto.bookingIds.length) { + const groupScheduleIds = new Set([scheduleId]); + if (schedule.direction !== 'EXPORT') { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + scheduleId, + ); + for (const sib of siblings) groupScheduleIds.add(sib.id); + } const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds); - const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId); + const stray = targeted.filter( + (b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId), + ); if (stray.length) { throw new BadRequestException( - `These bookings are not assigned to this schedule: ${stray + `These bookings are pinned to a train on a different route or day: ${stray .map((b) => b.reference ?? b.id) .join(', ')}`, ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index c94e005ae..df8871b07 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -149,8 +149,12 @@ export function ExportClearanceStepper({ const entityId = contractId ?? bookingId ?? ""; // The booking that carries the post-booking steps (gate pass, T1, invoice). const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + // Per-booking GENERAL clearance runs on a bare instance that only becomes a + // real booking once GL completes it — the caller's bookingCreated prop carries + // that signal, so a booking-keyed view must NOT count as "created" by itself + // (it would lock GL Ethiopia out of the declaration step right after the RO). const effectiveBookingCreated = - bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + bookingCreated || Boolean(clearance.linkedBookingId); const activeStep = useMemo( () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index bb271e205..79d35aa7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -248,6 +248,7 @@ export default function DocumentClearanceDetailPage() { // stepper's "Create booking" step must read as NOT-yet-created // so it never claims the booking is done before GL completes it. bookingCreated={Number(booking?.totalAmount ?? 0) > 0} + bookingMilestones={bookingMilestones ?? []} onChanged={() => void refetch()} onViewFile={view} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}