diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 057cb0c1b..f0beccd20 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,6 @@ name: Deploy Stacks on: push: branches: - - main - dev - staging workflow_dispatch: diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index d88029a80..984e622db 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -43,3 +43,4 @@ EXPOSE 3001 # GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT. EXPOSE 5023 CMD ["node", "dist/main.js"] + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 6bea9424c..bca74475e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -27,6 +27,7 @@ "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", + "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", diff --git a/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts new file mode 100644 index 000000000..997ced76d --- /dev/null +++ b/apps/edr-freight-api/src/common/dto/pagination-query.dto.ts @@ -0,0 +1,44 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +/** + * Base query DTO for every paginated list endpoint. Extend it and add the + * module's own filter fields; sort-field whitelists stay in the subclass + * because the allowed columns differ per resource. + * + * All list endpoints built on this return the shared `PaginatedResponse` + * envelope from `@edr/types` (`items` + `meta`), produced by + * `common/utils/pagination.util.ts`. + */ +export class PaginationQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Transform(({ value }) => parseInt(String(value), 10) || 1) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Transform(({ value }) => parseInt(String(value), 10) || 20) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @ApiPropertyOptional({ + description: 'Free-text search, applied server-side (resource-specific columns).', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + search?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @Transform(({ value }) => String(value).toUpperCase()) + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/apps/edr-freight-api/src/common/utils/pagination.util.ts b/apps/edr-freight-api/src/common/utils/pagination.util.ts new file mode 100644 index 000000000..310b2da6d --- /dev/null +++ b/apps/edr-freight-api/src/common/utils/pagination.util.ts @@ -0,0 +1,85 @@ +import { PaginatedResponse, PaginationMeta } from '@edr/types'; +import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; + +/** Raw page/pageSize as they arrive from a query DTO (both optional). */ +export interface PageRequest { + page?: number; + pageSize?: number; +} + +export interface PaginationOptions { + defaultPageSize?: number; + maxPageSize?: number; +} + +export interface NormalizedPage { + page: number; + pageSize: number; + skip: number; + take: number; +} + +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; + +/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */ +export function normalizePagination( + request: PageRequest, + options: PaginationOptions = {}, +): NormalizedPage { + const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE; + const maxPageSize = options.maxPageSize ?? MAX_PAGE_SIZE; + + const page = Math.max(1, Math.floor(request.page ?? 1) || 1); + const requested = Math.floor(request.pageSize ?? defaultPageSize) || defaultPageSize; + const pageSize = Math.min(Math.max(1, requested), maxPageSize); + + return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize }; +} + +export function buildPaginationMeta( + total: number, + page: number, + pageSize: number, +): PaginationMeta { + const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; + return { + page, + pageSize, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }; +} + +/** + * Apply skip/take to a query builder, run it, and wrap the result in the + * shared `PaginatedResponse` envelope. Ordering and filtering must already be + * applied by the caller. + */ +export async function paginateQuery( + qb: SelectQueryBuilder, + request: PageRequest, + options?: PaginationOptions, +): Promise> { + const { page, pageSize, skip, take } = normalizePagination(request, options); + const [items, total] = await qb.skip(skip).take(take).getManyAndCount(); + return { items, meta: buildPaginationMeta(total, page, pageSize) }; +} + +/** + * Paginate an already-materialized array. Prefer `paginateQuery` (DB-level + * LIMIT/OFFSET); use this only for lists that are inherently in-memory. + */ +export function paginateArray( + rows: readonly T[], + request: PageRequest, + options?: PaginationOptions, +): PaginatedResponse { + const { page, pageSize, skip } = normalizePagination(request, options); + return { + items: rows.slice(skip, skip + pageSize), + meta: buildPaginationMeta(rows.length, page, pageSize), + }; +} diff --git a/apps/edr-freight-api/src/migrations/2110000000000-AddBookingIsSplit.ts b/apps/edr-freight-api/src/migrations/2110000000000-AddBookingIsSplit.ts new file mode 100644 index 000000000..8e5ded189 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2110000000000-AddBookingIsSplit.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Partial-batch splits no longer promote a ONE_TIME contract to GENERAL. + * Instead the reduced booking is flagged is_split, and the booking gate lets + * the customer book exactly the remainder under the still-ONE_TIME contract. + */ +export class AddBookingIsSplit2110000000000 implements MigrationInterface { + name = 'AddBookingIsSplit2110000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE + `); + // Quantities the booking carried before the split — the remainder ledger + // for ONE_TIME contracts, which have no quantity cap to derive it from. + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities + `); + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts b/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts new file mode 100644 index 000000000..ed5054e87 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2120000000000-AddLastMileProofOfDelivery.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Proof of delivery for EDR last-mile: recipient name, a captured signature + * (stored as a file), delivery photos (file ids), notes, and the capture time. + * Recorded when the driver completes the delivery. + */ +export class AddLastMileProofOfDelivery2120000000000 + implements MigrationInterface +{ + name = "AddLastMileProofOfDelivery2120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160), + ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid, + ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS pod_notes text, + ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS pod_recipient_name, + DROP COLUMN IF EXISTS pod_signature_file_id, + DROP COLUMN IF EXISTS pod_photo_file_ids, + DROP COLUMN IF EXISTS pod_notes, + DROP COLUMN IF EXISTS pod_captured_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts b/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts new file mode 100644 index 000000000..651c4ba13 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2120000000000-AddScheduleWagonAllocationSnapshot.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add a frozen wagon-allocation snapshot to each train schedule. + * + * Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive / + * cancel), the same physical wagons get released and re-pinned onto later trains. + * The live wagon↔slot joins then no longer describe THIS train's plan, so an + * admin viewing a past schedule saw a mangled or "unavailable" allocation. + * + * This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot + * physical wagon + booking allocations) captured at the transition. Non-editable + * schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on + * legacy rows and while editable — the read path falls back to the live joins. + */ +export class AddScheduleWagonAllocationSnapshot2120000000000 + implements MigrationInterface +{ + name = "AddScheduleWagonAllocationSnapshot2120000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS wagon_allocation_snapshot; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a3009ec3c..6a04014a7 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -605,6 +605,7 @@ export class BookingsRepository extends BaseRepository { async findAllPaginated(options: BookingListFilterOptions & { page: number; pageSize: number; + search?: string; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }): Promise<{ @@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository { this.applyListFilters(qb, options); + // Free-text search spans joined columns (company, contract) that only this + // list query joins — so it lives here, not in applyListFilters (shared + // with getListSummaryMetrics, whose query builder has no joins). + if (options.search) { + qb.andWhere( + '(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', + { search: `%${options.search}%` }, + ); + } + if (options.sortBy === 'isGovernment') { qb.orderBy('booking.isGovernment', 'DESC') .addOrderBy('booking.priorityScore', 'DESC') @@ -1258,7 +1269,12 @@ export class BookingsRepository extends BaseRepository { fields: Partial< Pick< Booking, - 'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' + | 'schedulingStatus' + | 'wagonsRequired' + | 'scheduledAt' + | 'holdStartedAt' + | 'holdExpiresAt' + | 'trainScheduleId' > >, manager?: EntityManager, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 9f72c7409..dd5011df3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { Contract } from '../contracts/entities/contract.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { VehiclesService } from '../vehicles/vehicles.service'; @@ -1211,6 +1212,13 @@ export class BookingsService { destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, + // DTO carries 'true'/'false' strings (query params); the repo option is a + // real boolean — convert, preserving "not filtered" when absent. + customsClearingEnabled: + filter.customsClearingEnabled === undefined + ? undefined + : filter.customsClearingEnabled === 'true', + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -1262,6 +1270,7 @@ export class BookingsService { // Global Logistics only clears customs bookings; non-customs clearance is // reviewed by Marketing from the booking detail, not this queue. customsClearingEnabled: true, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -1286,6 +1295,7 @@ export class BookingsService { // Company-wide: payables span all of the customer's services. companyId: company.id, companyProfileId: filter.companyProfileId, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -1473,6 +1483,18 @@ export class BookingsService { ); } + // Surface the parent contract's reference for drawdown bookings — the + // portal detail header shows it (the entity has no contract relation, so + // the list attaches it via a raw join and the detail attaches it here). + if (booking.contractId) { + const contract = await this.dataSource.getRepository(Contract).findOne({ + where: { id: booking.contractId }, + select: { reference: true }, + }); + (booking as Booking & { contractReference?: string | null }).contractReference = + contract?.reference ?? null; + } + // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 4e364a03a..b61d0078d 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -2,18 +2,24 @@ import { BadRequestException, ConflictException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager, IsNull } from 'typeorm'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { NotificationsService } from '../notifications/notifications.service'; +import { sendCompanyChannels } from '../notifications/notify-company.util'; interface BookingGuardRow { tradeDirection: string | null; + freightType: string | null; firstMile: string | null; lastMile: string | null; paymentStatus: string | null; @@ -29,9 +35,13 @@ interface BookingGuardRow { */ @Injectable() export class CustomerTruckService { + private readonly logger = new Logger(CustomerTruckService.name); + constructor( private readonly dataSource: DataSource, private readonly assignments: CustomerTruckAssignmentsRepository, + private readonly inbox: NotificationInboxService, + private readonly notifications: NotificationsService, ) {} listTrucks(bookingId: string): Promise { @@ -41,14 +51,20 @@ export class CustomerTruckService { async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise { const booking = await this.loadBookingGuard(bookingId); this.assertSelfHaulPaid(booking); + this.assertAssignmentWindow(booking); - const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + // Bulk bookings have no containers — the truck hauls loose tonnage and is + // weighed out on departure (gross_weight_kg). Container bookings assign the + // 1–2 specific containers each truck carries. + const isBulk = booking.freightType === 'BULK'; + const requested = isBulk + ? [] + : (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - // Both import and export specify the containers each truck carries. Capacity - // is size-based: a 40ft container fills the truck (max 1); two 20ft containers - // fit (max 2), no size mixing. #trucks <= #containers follows naturally since - // each container is assigned to exactly one truck. - if (requested.length < 1) { + // Container capacity is size-based: a 40ft container fills the truck (max 1); + // two 20ft containers fit (max 2), no size mixing. #trucks <= #containers + // follows naturally since each container is assigned to exactly one truck. + if (!isBulk && requested.length < 1) { throw new BadRequestException('Select at least one container for this truck'); } if (requested.length > 2) { @@ -395,20 +411,74 @@ export class CustomerTruckService { }); if (!container) return; + const assignment = await m + .getRepository(CustomerTruckAssignment) + .findOne({ where: { id: container.assignmentId } }); + const justArrived = Boolean(assignment) && !assignment?.arrivedAt; + await m .getRepository(CustomerTruckAssignment) .update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); await this.syncBookingArrival(bookingId, m); + + if (justArrived && assignment) { + await this.notifyTruckArrival(bookingId, assignment.plateNumber, m); + } } /** Mark every truck on the booking arrived (fallback when no container is known). */ async markAllArrived(bookingId: string, manager?: EntityManager): Promise { const m = manager ?? this.dataSource.manager; + const justArrived = await m + .getRepository(CustomerTruckAssignment) + .find({ where: { bookingId, arrivedAt: IsNull() } }); await m .getRepository(CustomerTruckAssignment) .update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() }); await this.syncBookingArrival(bookingId, m); + for (const truck of justArrived) { + await this.notifyTruckArrival(bookingId, truck.plateNumber, m); + } + } + + /** + * Best-effort truck-arrival notification to the booking's company across every + * channel: in-app (portal inbox) + SMS + email. Never throws — a missing + * provider or contact must not break the arrival flow. + */ + private async notifyTruckArrival( + bookingId: string, + plateNumber: string | null, + m: EntityManager, + ): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await m.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck'; + const body = `${truck} has arrived at the terminal for booking ${ref}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck arrived', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`, + ); + } } /** @@ -430,6 +500,7 @@ export class CustomerTruckService { private async loadBookingGuard(bookingId: string): Promise { const [row]: BookingGuardRow[] = await this.dataSource.query( `SELECT trade_direction AS "tradeDirection", + freight_type AS "freightType", first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", @@ -463,6 +534,30 @@ export class CustomerTruckService { } } + /** + * Assignment window by direction: + * - IMPORT: pickup trucks are assigned only AFTER the train has arrived. + * - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is + * loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded + * (IN_TRANSIT and beyond) assignment is closed. + */ + private assertAssignmentWindow(booking: BookingGuardRow): void { + const status = booking.status ?? ''; + if (booking.tradeDirection === 'IMPORT') { + if (status !== 'ARRIVED') { + throw new BadRequestException( + 'Import pickup trucks can only be assigned after the train has arrived', + ); + } + return; + } + if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) { + throw new BadRequestException( + 'Export delivery trucks can only be assigned before the cargo is loaded onto the train', + ); + } + } + private async bookingContainerNumbers(bookingId: string): Promise { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber" diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index ae973b97d..2d3297af8 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -106,6 +106,14 @@ export class FilterBookingDto { @IsIn(['true', 'false']) isGovernment?: 'true' | 'false'; + @ApiPropertyOptional({ + enum: ['true', 'false'], + description: 'Filter customs vs self-clearance (non-customs) bookings', + }) + @IsOptional() + @IsIn(['true', 'false']) + customsClearingEnabled?: 'true' | 'false'; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) @@ -125,6 +133,16 @@ export class FilterBookingDto { @IsOptional() consolidationPaired?: string; + @ApiPropertyOptional({ + description: + 'Free-text search across booking reference, company name, and contract reference.', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + search?: string; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @Transform(({ value }) => (value ? parseInt(value, 10) : 1)) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 398c64809..4cc15854e 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -166,6 +166,24 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) contractKind?: string | null; + /** + * The customer paid a partial batch offer and this booking was reduced to the + * offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a + * split booking releases the single-active-booking slot for the remainder — + * the contract kind itself is never changed. + */ + @Column({ name: 'is_split', type: 'boolean', default: false }) + isSplit!: boolean; + + /** + * Quantities this booking carried BEFORE it was reduced by a split — the + * split chain's source of truth for the outstanding remainder (ONE_TIME + * contracts have no quantity cap to derive it from). Bulk: total tons; + * container: units per size. Null until the booking is split. + */ + @Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true }) + preSplitQuantities?: { bulkTons?: number; bySize?: Record } | null; + /** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */ @Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true }) createdByRole?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 1005409e0..a17406d64 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -199,6 +199,8 @@ export class BookingRequestService { reviewedByStaffId: staffId ?? null, reviewedAt: new Date(), } as never); + const contract = await this.contractsService.findById(request.contractId); + this.notifier.shipmentRequestRejected(contract, request.reference, note); return (await this.repo.findById(requestId)) ?? request; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 4e21078ff..7dc676398 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // invoiceService {} as never, // dataSource {} as never, // trainSchedulingService + {} as never, // bookingBatchService {} as never, // bookingTransitionService ); return { service, contractsRepository }; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 7225d6e3f..c2e3a107b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { invoiceService as never, {} as never, // dataSource {} as never, // trainSchedulingService + {} as never, // bookingBatchService {} as never, // bookingTransitionService ); return { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index c09f2ccb0..f055a0c15 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -24,9 +24,11 @@ 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 { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; @@ -40,6 +42,11 @@ import { CreateBookingUnderContractDto } from './dto/create-booking-under-contra /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; +/** Bookings that never shipped release their quantity hold on the contract. */ +const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED']; + +const round3 = (n: number): number => Math.round(n * 1000) / 1000; + export interface CreateBookingUnderContractResult { booking: Booking; warnings: string[]; @@ -74,6 +81,8 @@ export class ContractBookingService { private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingTransitionService)) private readonly bookingTransitionService: BookingTransitionService, ) {} @@ -90,10 +99,22 @@ export class ContractBookingService { // A contract whose quantity cap was fully booked is completed — no further // bookings, even while contract validity and a booking window are still // open. Capacity released after closure (a cancelled/expired booking) - // reopens the contract on the next booking attempt. + // reopens the contract on the next booking attempt. A ONE_TIME contract + // only closes via a finished split chain, so its room is the outstanding + // split remainder rather than a cap line. if (contract.status === 'CONTRACT_CLOSED') { - const capacity = await this.computeCapacity(contract); - const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); + let hasRoom: boolean; + if (contract.contractKind !== 'GENERAL') { + const outstanding = await this.splitOutstanding(contract); + hasRoom = outstanding + ? contract.freightType === 'CONTAINER' + ? [...outstanding.bySize.values()].some((s) => s.outstanding > 0) + : (outstanding.bulk?.outstanding ?? 0) > 0.001 + : false; + } else { + const capacity = await this.computeCapacity(contract); + hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); + } if (!hasRoom) { throw new BadRequestException( 'This contract is completed — the full contracted quantity has been booked.', @@ -121,12 +142,21 @@ export class ContractBookingService { // ONE_TIME: a single shipment at a time. The slot frees only if the prior // booking reached a terminal state (e.g. payment expired without shipping), // letting the customer re-book within contract validity (doc §10.4). + // EXCEPTION — split chain: a paid partial split (booking.isSplit) releases + // the slot for the leftover, but the next booking must take the WHOLE + // remainder; the customer cannot start any other booking on the contract. + // If the remainder splits again the same rule repeats until the cap is + // exhausted and the contract completes. if (contract.contractKind === 'ONE_TIME') { - const active = await this.countActiveBookings(contractId); - if (active > 0) { - throw new BadRequestException( - 'This one-time contract already has an active booking.', - ); + if (await this.hasSplitBooking(contractId)) { + await this.assertExactRemainder(contract, dto); + } else { + const active = await this.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + 'This one-time contract already has an active booking.', + ); + } } } else { // GENERAL: draw down against the cargo quantity cap until it is full. @@ -181,6 +211,10 @@ export class ContractBookingService { scheduledDate: dto.scheduledDate ?? null, direction: contract.tradeDirection ?? null, }); + // EXPORT rides whole or not at all (no split concept): reject the booking + // up front when no single open train on the day can carry it, telling the + // customer how much space is still bookable. + await this.assertExportTrainSpace(contract, route, dto); } // Hard capacity gate: a container line whose total weight exceeds the @@ -573,6 +607,31 @@ export class ContractBookingService { Number(booking.cargoTotalWeightVgm) > 0; const warnings: string[] = []; + // EXPORT rides whole or not at all (no split concept): the chosen day must + // have a single open train that carries the whole booking. First completion + // sizes from the dto's cargo; a changes-requested resubmit (cargo already + // persisted, only the day re-picked) sizes from the booking itself. + if (contract.tradeDirection === 'EXPORT') { + if (hasCargo) { + const probe = Object.assign( + Object.create(Object.getPrototypeOf(booking)), + booking, + { scheduledDate: new Date(dto.scheduledDate) }, + ) as Booking; + const report = await this.bookingBatchService.exportSpaceReport(probe); + if (!report.scheduleId) { + throw new BadRequestException( + report.fullMessage ?? 'Not enough train space for this day.', + ); + } + } else { + await this.assertExportTrainSpace(contract, null, dto, { + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + }); + } + } + // First completion persists cargo and draws contract capacity; a resubmit // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks // the shipment day. @@ -869,6 +928,196 @@ export class ContractBookingService { .getCount(); } + /** + * Whether the contract is in split-remainder mode: some booking on it was + * reduced by a paid partial batch offer and still holds capacity. A split + * booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its + * hold and the contract falls back to the plain single-slot rule — the + * customer can rebook the whole quantity again. + */ + private async hasSplitBooking(contractId: string): Promise { + const count = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId }) + .andWhere('b.is_split = true') + .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES }) + .getCount(); + return count > 0; + } + + /** + * Outstanding split remainder of a ONE_TIME contract: what the FIRST split + * booking carried before its reduction (its pre_split_quantities snapshot — + * one-time contracts have no quantity cap to derive this from) minus + * everything currently booked on the contract. Bookings that never shipped + * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the + * contract has no live split booking. + */ + private async splitOutstanding( + contract: Contract, + ): Promise<{ bySize: Map; bulk: { total: number; outstanding: number } | null } | null> { + const first = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId: contract.id }) + .andWhere('b.is_split = true') + .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES }) + .orderBy('b.created_at', 'ASC') + .getOne(); + if (!first?.preSplitQuantities) return null; + + const booked = await this.bookedQuantities(contract); + if (contract.freightType === 'CONTAINER') { + const bySize = new Map(); + for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) { + bySize.set(size, { + total: Number(total), + outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)), + }); + } + return { bySize, bulk: null }; + } + const total = Number(first.preSplitQuantities.bulkTons ?? 0); + return { + bySize: new Map(), + bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) }, + }; + } + + /** + * EXPORT whole-booking single-train gate. Export bookings never split — the + * entire booking must ride ONE open train on the chosen day. When no train + * fits it whole (trying every fillable train on the corridor, earliest + * first), reject BEFORE anything is written, with the largest still-bookable + * space (tons for bulk via the cargo type's wagon type; wagons/containers + * for container freight) so the customer knows what he CAN book. + */ + private async assertExportTrainSpace( + contract: Contract, + route: ContractRoute | null, + dto: CreateBookingUnderContractDto, + yards?: { originYardId: string | null; destinationYardId: string | null }, + ): Promise { + if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return; + const probe = await this.buildExportProbe(contract, route, dto, yards); + const report = await this.bookingBatchService.exportSpaceReport(probe); + if (report.scheduleId) return; + throw new BadRequestException( + report.fullMessage ?? 'Not enough train space for this day.', + ); + } + + /** + * Unsaved booking twin carrying exactly what the batch engine's capacity + * math reads: yards + day for the leg, container lines WITH their container + * type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type + * (wagon-type FK) for tons→wagons conversion. + */ + private async buildExportProbe( + contract: Contract, + route: ContractRoute | null, + dto: CreateBookingUnderContractDto, + yards?: { originYardId: string | null; destinationYardId: string | null }, + ): Promise { + const probe = new Booking(); + probe.freightType = contract.freightType; + probe.tradeDirection = contract.tradeDirection; + probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null; + // Entity types are non-nullable; a missing yard just makes legOf() match no + // train, which surfaces as "no export train for this day" — the right failure. + probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string; + probe.destinationYardId = (yards?.destinationYardId ?? + route?.destinationYardId) as string; + + if (contract.freightType === 'CONTAINER') { + const lines = await Promise.all( + (dto.containers ?? []).map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const bc = new BookingContainer(); + bc.containerSize = line.containerSize; + bc.quantity = line.quantity; + bc.containerTypeId = ct.id; + bc.containerType = ct; + bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)); + bc.totalVgmTons = (line.units ?? []).reduce( + (sum, u) => sum + Number(u.vgmTons ?? 0), + 0, + ); + return bc; + }), + ); + probe.bookingContainers = lines; + probe.cargoTotalWeightVgm = lines.reduce( + (sum, l) => sum + Number(l.totalVgmTons ?? 0), + 0, + ); + return probe; + } + + probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); + const cargoTypeId = this.resolveCargoTypeId(contract, dto); + probe.cargoTypeId = cargoTypeId; + if (cargoTypeId) { + probe.cargoType = + (await this.dataSource + .getRepository(CargoType) + .findOne({ where: { id: cargoTypeId } })) ?? undefined; + } + return probe; + } + + /** + * ONE_TIME split chain: the next booking must take the WHOLE outstanding + * remainder — a one-time contract is a single shipment, so the only way it + * fragments is the system splitting it on train capacity, never the customer + * choosing a partial amount. + */ + private async assertExactRemainder( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const outstanding = await this.splitOutstanding(contract); + if (!outstanding) return; // no live split booking — nothing to pin the remainder to + + if (contract.freightType === 'CONTAINER') { + const sizes = new Set([ + ...outstanding.bySize.keys(), + ...(dto.containers ?? []).map((l) => l.containerSize ?? ''), + ]); + for (const size of sizes) { + const remaining = outstanding.bySize.get(size)?.outstanding ?? 0; + const requested = (dto.containers ?? []) + .filter((l) => (l.containerSize ?? '') === size) + .reduce((sum, l) => sum + Number(l.quantity ?? 0), 0); + if (requested !== remaining) { + throw new BadRequestException( + `This one-time contract was split — the next booking must take the whole remainder: ` + + `${remaining} × ${size || 'container'} container(s), got ${requested}.`, + ); + } + } + return; + } + + const requested = + (dto.bulkLines ?? []).reduce( + (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), + 0, + ) || this.resolveBulkTons(dto) || 0; + const remaining = outstanding.bulk?.outstanding ?? 0; + // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights. + if (Math.abs(requested - remaining) > 0.001) { + throw new BadRequestException( + `This one-time contract was split — the next booking must take the whole ` + + `remaining ${remaining} tons, got ${requested}.`, + ); + } + } + // ── GENERAL contract quantity cap (draw-down) ────────────────────────────── /** @@ -980,34 +1229,96 @@ export class ContractBookingService { }); } + /** + * Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap + * lines as-is, or — for a ONE_TIME contract in split-remainder mode — + * synthesized lines whose cap is the first split booking's pre-split + * snapshot and whose remaining is the outstanding remainder, i.e. the exact + * quantity the next booking must take. + */ + async capacityView( + contract: Contract, + ): Promise< + Array<{ + containerSize?: string | null; + cargoTypeId?: string | null; + cap: number | null; + booked: number; + remaining: number | null; + }> + > { + const capacity = await this.computeCapacity(contract); + if (capacity.length > 0 || contract.contractKind === 'GENERAL') { + return capacity; + } + const outstanding = await this.splitOutstanding(contract); + if (!outstanding) return capacity; + if (contract.freightType === 'CONTAINER') { + return [...outstanding.bySize.entries()].map(([size, s]) => ({ + containerSize: size, + cargoTypeId: null, + cap: s.total, + booked: s.total - s.outstanding, + remaining: s.outstanding, + })); + } + const bulk = outstanding.bulk; + if (!bulk) return []; + return [ + { + containerSize: null, + cargoTypeId: null, + cap: bulk.total, + booked: round3(bulk.total - bulk.outstanding), + remaining: bulk.outstanding, + }, + ]; + } + /** * Complete the contract once its quantity cap is fully consumed. Runs after - * every booking created under a GENERAL contract (including a split remainder - * being rebooked): when no capped scope line has capacity left, the contract - * moves to CONTRACT_CLOSED even though its validity window is still open — - * blocking further bookings and shipment requests, including inside an open - * booking window. Never throws: a status hiccup must not undo the booking - * that was just created. + * every booking created under a GENERAL contract, and under a ONE_TIME + * contract in split-remainder mode (a split remainder being rebooked): when + * no capped scope line has capacity left, the contract moves to + * CONTRACT_CLOSED even though its validity window is still open — blocking + * further bookings and shipment requests, including inside an open booking + * window. Never throws: a status hiccup must not undo the booking that was + * just created. */ private async maybeCompleteContract(contract: Contract): Promise { try { - // ONE_TIME contracts are governed by the single-active-booking slot (and - // are promoted to GENERAL on split), so only GENERAL completes by cap. - if (contract.contractKind !== 'GENERAL') return; if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; - const capacity = await this.computeCapacity(contract); - if (capacity.length === 0) return; // uncapped — completes only by expiry - // 0.001 tolerance absorbs bulk-ton float rounding (split weights round to - // 3 decimals); container caps are integers and unaffected. - const exhausted = capacity.every( - (c) => c.remaining != null && c.remaining <= 0.001, - ); - if (!exhausted) return; + + // ONE_TIME contracts are governed by the single-active-booking slot, so + // they normally complete by expiry — EXCEPT once a booking was split: the + // remainder chain draws down the split booking's pre-split snapshot, and + // the contract completes when the outstanding remainder hits zero. + // (An unsplit ONE_TIME never completes here, so re-booking after an + // expired unpaid booking keeps working.) + if (contract.contractKind !== 'GENERAL') { + const outstanding = await this.splitOutstanding(contract); + if (!outstanding) return; + // 0.001 tolerance absorbs bulk-ton float rounding (split weights round + // to 3 decimals); container quantities are integers and unaffected. + const exhausted = + contract.freightType === 'CONTAINER' + ? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0) + : (outstanding.bulk?.outstanding ?? 0) <= 0.001; + if (!exhausted) return; + } else { + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped — completes only by expiry + const exhausted = capacity.every( + (c) => c.remaining != null && c.remaining <= 0.001, + ); + if (!exhausted) return; + } + await this.contractsRepository.update(contract.id, { status: 'CONTRACT_CLOSED', } as never); this.logger.log( - `Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`, + `Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`, ); } catch (err) { this.logger.error( @@ -1025,7 +1336,7 @@ export class ContractBookingService { private async bookedQuantities( contract: Contract, ): Promise<{ bySize: Map; bulk: number }> { - const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED']; + const releasing = RELEASING_BOOKING_STATUSES; if (contract.freightType === 'CONTAINER') { const rows = await this.dataSource .getRepository(BookingContainer) @@ -1213,6 +1524,8 @@ export class ContractBookingService { currency: string | null; pairingErrors: string[]; capacityErrors: string[]; + containerClashErrors: string[]; + spaceErrors: string[]; lineItems: PriceLineItemDto[]; totalAmount: number; }> { @@ -1227,6 +1540,8 @@ export class ContractBookingService { currency: null, pairingErrors: [], capacityErrors: [], + containerClashErrors: [], + spaceErrors: [], lineItems: [], totalAmount: 0, }; @@ -1312,12 +1627,50 @@ export class ContractBookingService { contract.tradeDirection, ); + // A physical container rides one train only — surface a clash with another + // active booking on the same day + route in the preview, so the form can + // hard-block before the create call rejects with the same rule. + let containerClashErrors: string[] = []; + if (dto.scheduledDate) { + const numbers = lines.flatMap((line) => + (line.units ?? []) + .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) + .filter((n) => n.length > 0), + ); + const clashes = await this.findContainerClashesOnTrain( + [...new Set(numbers)], + dto.scheduledDate, + { + originYardId: route?.originYardId, + destinationYardId: route?.destinationYardId, + }, + ); + containerClashErrors = clashes.map( + (c) => + `${c.containerNumber} is already booked on ${c.reference} for this shipment day.`, + ); + } + + // EXPORT rides whole or not at all — surface the single-train space check + // in the preview so the form hard-blocks BEFORE the create call rejects + // with the same message (including how much space is still bookable). + let spaceErrors: string[] = []; + if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) { + const probe = await this.buildExportProbe(contract, route, dto); + const report = await this.bookingBatchService.exportSpaceReport(probe); + if (!report.scheduleId) { + spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.']; + } + } + return { overweightLines: computed.overweightLines, overweightSurchargeAmount, currency: computed.currency, pairingErrors, capacityErrors, + containerClashErrors, + spaceErrors, lineItems: computed.lineItems, totalAmount: computed.totalAmount, }; @@ -1445,6 +1798,37 @@ export class ContractBookingService { route: { originYardId?: string | null; destinationYardId?: string | null }, excludeBookingId?: string, ): Promise { + const clashes = await this.findContainerClashesOnTrain( + numbers, + scheduledDate, + route, + excludeBookingId, + ); + + if (clashes.length) { + const detail = clashes + .map((c) => `${c.containerNumber} (booking ${c.reference})`) + .join(', '); + throw new ConflictException( + `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + + 'A container can only be on one booking per train — remove it or pick another shipment day.', + ); + } + } + + /** + * Container numbers among `numbers` that already sit on another active + * booking of the same train — same day and same route. One row per clashing + * number. Bookings without route yards (legacy rows) match on the day alone + * rather than let through. + */ + private async findContainerClashesOnTrain( + numbers: string[], + scheduledDate: string, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise> { + if (!numbers.length) return []; const qb = this.dataSource .getRepository(BookingContainerUnit) .createQueryBuilder('unit') @@ -1474,18 +1858,7 @@ export class ContractBookingService { } const clashes: Array<{ containerNumber: string; reference: string }> = await qb.getRawMany(); - - if (clashes.length) { - const detail = [ - ...new Map(clashes.map((c) => [c.containerNumber, c])).values(), - ] - .map((c) => `${c.containerNumber} (booking ${c.reference})`) - .join(', '); - throw new ConflictException( - `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + - 'A container can only be on one booking per train — remove it or pick another shipment day.', - ); - } + return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()]; } private async assert20ftPairableAtCreate( @@ -1528,8 +1901,8 @@ export class ContractBookingService { preferReefer: boolean, ): Promise { const sizeFt = parseInt(size, 10); - const { data } = await this.containerTypesService.findAll({ pageSize: 200 }); - const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false); + const { items } = await this.containerTypesService.findAll({ pageSize: 100 }); + const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false); if (!types.length) { throw new BadRequestException(`No container type configured for size ${size}.`); } 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 3d41e68a5..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, }); @@ -866,12 +870,38 @@ export class ContractClearanceService { * Operations queue: self-clearance (Path A) contracts awaiting Operations * review of the customer's own clearance documents. */ + /** + * Statuses a non-customs contract passes through around Operations + * clearance review — the set a caller may narrow {@link opsQueue} to. + */ + private static readonly OPS_CLEARANCE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'CONTRACT_CLOSED', + 'CANCELLED', + ]; + async opsQueue(filter: FilterContractDto): Promise { + // Callers may narrow to any subset of the ops-clearance lifecycle (the + // hub's status filter sends an explicit list); anything outside the + // whitelist is dropped so this endpoint can't become a general contract + // browser. No statuses given → the original under-review queue. + const requested = (filter.statuses ?? filter.status ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => + ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s), + ); return this.contractsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 100, - statuses: ['CLEARANCE_UNDER_REVIEW'], + statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: false, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -896,6 +926,7 @@ export class ContractClearanceService { pageSize: filter.pageSize ?? 50, statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], customsClearingEnabled: false, + search: filter.search, sortBy: filter.sortBy ?? 'createdAt', sortOrder: filter.sortOrder ?? 'DESC', }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index d4f31e570..3834b55f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -145,6 +145,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract changes requested', msg); } + /** GL rejected a shipment request filed under the contract. */ + shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void { + const msg = + `Your shipment request ${requestRef} under contract ${c.reference} was rejected.` + + (note ? ` Reason: ${note}.` : '') + + ` Please contact us for details.`; + void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED'); + this.inApp(c, 'Shipment request rejected', msg, { + type: NotificationType.BOOKING_STATUS, + data: { contractId: c.id, reference: requestRef }, + }); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 8ce7ad2ea..286cd9a01 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -80,9 +80,9 @@ export class ContractPricingService { const sizes = (contract.cargoScope ?? []) .map((c) => c.containerSize) .filter((s): s is string => !!s); - const { data: containerTypes } = await this.containerTypesService.findAll({ + const { items: containerTypes } = await this.containerTypesService.findAll({ isActive: true, - pageSize: 500, + pageSize: 100, }); for (const size of sizes) { const sizeFt = size === '40ft' ? 40 : 20; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 95b734c60..2b79d3274 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -852,11 +852,12 @@ export class ContractsController { @Get(':id/capacity') @ApiOperation({ - summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', + summary: + 'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)', }) async capacity(@Param('id', ParseUUIDPipe) id: string) { const contract = await this.contractsService.findById(id); - return this.contractBookingService.computeCapacity(contract); + return this.contractBookingService.capacityView(contract); } // ── Clearance milestones (doc §11.3, §12.2) ──────────────────────────────── 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 e53ba0e13..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; } @@ -98,6 +100,7 @@ export class ContractsRepository extends BaseRepository { options: ContractListFilterOptions & { page: number; pageSize: number; + search?: string; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }, @@ -128,6 +131,16 @@ export class ContractsRepository extends BaseRepository { this.applyListFilters(qb, options); + // Free-text search across contract reference and customer (company) name. + // Applied here (not in applyListFilters) because only this query joins the + // `company` alias — the summary-metrics query builder does not. + if (options.search) { + qb.andWhere( + '(contract.reference ILIKE :search OR company.name ILIKE :search)', + { search: `%${options.search}%` }, + ); + } + const sortField = options.sortBy === 'contractValidUntil' ? 'contract.contractValidUntil' @@ -273,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/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 517d818bf..224745cc4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -579,6 +579,7 @@ export class ContractsService { paymentCurrency: filter.paymentCurrency, createdFrom: filter.createdFrom, createdTo: filter.createdTo, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts index 9c1c60921..8950e7eb3 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -71,6 +71,15 @@ export class FilterContractDto { @IsDateString() createdTo?: string; + @ApiPropertyOptional({ + description: 'Free-text search across contract reference and company name.', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + search?: string; + @ApiPropertyOptional({ default: 1 }) @IsOptional() @Transform(({ value }) => (value ? parseInt(value, 10) : 1)) diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index 7a63964d8..aad904557 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -10,12 +10,14 @@ import { Patch, Post, Put, + Query, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FreightAdmin } from "../../common/booking-guards"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; import { DropdownSettingsService } from "./dropdown-settings.service"; @@ -34,6 +36,15 @@ export class DropdownSettingsController { return this.service.list(); } + // Must be declared before @Get(":id") so "paged" isn't captured as an id. + @Get("paged") + @ApiOperation({ + summary: "Paged admin listing of dropdown settings (server-side search)", + }) + listPaged(@Query() query: ListDropdownSettingsQueryDto) { + return this.service.listPaged(query); + } + @Get(":id") @ApiOperation({ summary: "Get a dropdown setting by ID" }) getById(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts index a4f851e53..1cf12b219 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.repository.ts @@ -1,8 +1,11 @@ import { BaseRepository } from "@edr/api-common"; +import { PaginatedResponse } from "@edr/types"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from "typeorm"; +import { paginateQuery } from "../../common/utils/pagination.util"; +import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto"; import { DropdownOption } from "./entities/dropdown-option.entity"; import { DropdownSetting } from "./entities/dropdown-setting.entity"; import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface"; @@ -44,6 +47,27 @@ export class DropdownSettingsRepository }); } + findPaged( + query: ListDropdownSettingsQueryDto, + ): Promise> { + // Soft-deleted rows are excluded automatically by the query builder + // (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC). + const qb = this.repository + .createQueryBuilder("setting") + .leftJoinAndSelect("setting.children", "option") + .orderBy("setting.label", query.sortOrder ?? "ASC") + .addOrderBy("option.order", "ASC"); + + if (query.search) { + qb.andWhere( + "(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)", + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async replaceOptions( settingId: string, options: Array>, diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts index 530cfded4..dda6e1294 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.service.ts @@ -5,8 +5,11 @@ import { NotFoundException, } from "@nestjs/common"; +import { PaginatedResponse } from "@edr/types"; + import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; +import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto"; import { DropdownOption } from "./entities/dropdown-option.entity"; @@ -16,71 +19,6 @@ import { IDropdownSettingsRepository, } from "./interfaces/dropdown-settings.repository.interface"; -const STATIONS_TER_CODE = "stations_ter"; - -const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [ - { - value: "inside_addis_ababa", - label: "Addis Ababa", - note: "Inside country", - order: 1, - }, - { - value: "inside_adama", - label: "Adama", - note: "Inside country", - order: 2, - }, - { - value: "inside_mojo", - label: "Mojo", - note: "Inside country", - order: 3, - }, - { - value: "inside_awash", - label: "Awash", - note: "Inside country", - order: 4, - }, - { - value: "inside_mieso", - label: "Mieso", - note: "Inside country", - order: 5, - }, - { - value: "inside_dire_dawa", - label: "Dire Dawa", - note: "Inside country", - order: 6, - }, - { - value: "outside_ali_sabieh", - label: "Ali Sabieh", - note: "Outside country", - order: 7, - }, - { - value: "outside_holhol", - label: "Holhol", - note: "Outside country", - order: 8, - }, - { - value: "outside_djibouti_city", - label: "Djibouti City", - note: "Outside country", - order: 9, - }, - { - value: "outside_doraleh_terminal", - label: "Doraleh Terminal", - note: "Outside country", - order: 10, - }, -]; - @Injectable() export class DropdownSettingsService { constructor( @@ -92,6 +30,12 @@ export class DropdownSettingsService { return this.repository.findAll(); } + listPaged( + query: ListDropdownSettingsQueryDto, + ): Promise> { + return this.repository.findPaged(query); + } + async getById(id: string): Promise { const setting = await this.repository.findById(id); if (!setting) throw new NotFoundException(`Setting ${id} not found`); @@ -127,34 +71,6 @@ export class DropdownSettingsService { return this.getById(setting.id); } - async seedDefaultStations(): Promise { - const existing = await this.repository.findByCode(STATIONS_TER_CODE); - - if (!existing) { - await this.create({ - code: STATIONS_TER_CODE, - label: "Stations TER", - description: - "Temporary freight station list used by booking origin and destination yards.", - multiple: false, - meta: { - searchable: true, - clearable: true, - version: "temporary", - }, - children: DEFAULT_STATION_OPTIONS, - }); - return; - } - - if ((existing.children?.length ?? 0) === 0) { - await this.repository.replaceOptions( - existing.id, - DEFAULT_STATION_OPTIONS, - ); - } - } - async update( id: string, dto: UpdateDropdownSettingDto, diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dto/list-dropdown-settings-query.dto.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dto/list-dropdown-settings-query.dto.ts new file mode 100644 index 000000000..d98b3bf36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dto/list-dropdown-settings-query.dto.ts @@ -0,0 +1,8 @@ +import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto"; + +/** + * Query params for the paged admin listing (`GET /dropdown-settings/paged`). + * `search` matches code, label and description server-side. The entity has no + * status/isActive flag, so the base pagination fields are all that's needed. + */ +export class ListDropdownSettingsQueryDto extends PaginationQueryDto {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts index 7b004a066..e0def4663 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/interfaces/dropdown-settings.repository.interface.ts @@ -1,3 +1,6 @@ +import { PaginatedResponse } from "@edr/types"; + +import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto"; import { DropdownOption } from "../entities/dropdown-option.entity"; import { DropdownSetting } from "../entities/dropdown-setting.entity"; @@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol( export interface IDropdownSettingsRepository { findAll(): Promise; + findPaged( + query: ListDropdownSettingsQueryDto, + ): Promise>; findById(id: string): Promise; findByCode(code: string): Promise; diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts new file mode 100644 index 000000000..44bf2f9c9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/record-proof-of-delivery.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Proof of delivery captured by the EDR driver when a last-mile leg is + * completed. Sent as multipart/form-data — the recipient's signature (field + * `signature`) and proof photos (field `photos`) are uploaded alongside these + * text fields. + */ +export class RecordProofOfDeliveryDto { + @ApiProperty({ description: 'Name of the person who received the cargo.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + recipientName!: string; + + @ApiPropertyOptional({ description: 'Optional delivery notes.' }) + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts index 5dca86cc4..833911365 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile.entity.ts @@ -70,4 +70,22 @@ export class LastMile extends BaseEntity { @OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile) vehicleAssignments?: LastMileVehicleAssignment[]; + + // ── Proof of delivery (captured by the EDR driver on completion) ────────── + @Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true }) + podRecipientName?: string | null; + + /** File id of the recipient's captured signature (PNG). */ + @Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true }) + podSignatureFileId?: string | null; + + /** File ids of the delivery proof photos. */ + @Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' }) + podPhotoFileIds!: string[]; + + @Column({ name: 'pod_notes', type: 'text', nullable: true }) + podNotes?: string | null; + + @Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true }) + podCapturedAt?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 207b71a25..abee4d53d 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -11,8 +11,11 @@ import { Patch, Post, Query, + UploadedFiles, + UseInterceptors, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; @@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; +import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileService } from './last-mile.service'; import { LastMileInvoiceService } from './last-mile-invoice.service'; @@ -115,6 +119,19 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/proof-of-delivery') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' }) + async recordProofOfDelivery( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RecordProofOfDeliveryDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []); + } + @Post(':id/invoice') @BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice) @ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index e639e4dfd..289c2156f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingModule } from '../billing/billing.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { FilesModule } from '../files/files.module'; import { DriversModule } from '../drivers/drivers.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { VehiclesModule } from '../vehicles/vehicles.module'; @@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service'; VehiclesModule, DriversModule, NotificationsModule, + FilesModule, ], controllers: [LastMileController], providers: [LastMileRepository, LastMileService, LastMileInvoiceService], diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 15b8df3e7..999137e6e 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; +import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; import { LastMileRepository } from './last-mile.repository'; +import { FilesService } from '../files/files.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; import { OnEvent } from '@nestjs/event-emitter'; import { FleetHistoryService } from '../fleet-history/fleet-history.service'; @@ -47,6 +49,7 @@ export class LastMileService { private readonly dataSource: DataSource, private readonly history: FleetHistoryService, private readonly billing: BillingService, + private readonly filesService: FilesService, ) {} /** Attach real invoice info (number/status) to records so the UI can show an @@ -210,6 +213,49 @@ export class LastMileService { return record; } + /** + * Record proof of delivery (recipient signature + photos + notes) and complete + * the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt, + * vehicle release, history). + */ + async recordProofOfDelivery( + id: string, + dto: RecordProofOfDeliveryDto, + files: Express.Multer.File[], + ): Promise { + const existing = await this.findById(id); + + const signature = files.find((f) => f.fieldname === 'signature'); + const photos = files.filter((f) => f.fieldname === 'photos'); + + const signatureFileId = signature + ? ( + await this.filesService.upload({ + resourceId: id, + resource: 'last-mile', + code: 'pod-signature', + file: signature, + }) + ).id + : null; + const photoFileIds = photos.length + ? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id) + : []; + + await this.lastMileRepository.update(id, { + podRecipientName: dto.recipientName.trim(), + podSignatureFileId: signatureFileId, + podPhotoFileIds: photoFileIds, + podNotes: dto.notes?.trim() || null, + podCapturedAt: new Date(), + } as never); + + if (existing.status !== 'DELIVERED') { + return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto); + } + return this.findById(id); + } + async create(dto: CreateLastMileDto): Promise { const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 8e13d3ec7..8d9ed00c9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; @@ -19,15 +20,8 @@ export class ApprovalRulesController { @Get() @RuleEngineView('approval-rules') @ApiOperation({ summary: 'List approval rules' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - requiresDirectorApproval: - query['requiresDirectorApproval'] !== undefined - ? query['requiresDirectorApproval'] === 'true' - : undefined, - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListApprovalRulesQueryDto) { + return this.service.findAll(query); } @Get('chain') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index e2b8425bf..421e49165 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -5,6 +5,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; @@ -19,19 +20,8 @@ export class CargoTypesController { @Get() @RuleEngineView('cargo-types') @ApiOperation({ summary: 'List cargo types' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined - ? query['requiresDirectorApproval'] === 'true' - : undefined, - parentGroupId: query['parentGroupId'], - search: query['search'], - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - sortBy: query['sortBy'], - sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', - }); + findAll(@Query() query: ListCargoTypesQueryDto) { + return this.service.findAll(query); } @Post('reorder') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 624cf4b03..3c1c27c7c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -5,6 +5,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; @@ -19,12 +20,8 @@ export class ContainerTypesController { @Get() @RuleEngineView('container-types') @ApiOperation({ summary: 'List container types' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListContainerTypesQueryDto) { + return this.service.findAll(query); } @Post('reorder') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts index 8dcc58e77..99eaabf3f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-configs.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; +import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; @@ -19,13 +20,8 @@ export class PriorityConfigsController { @Get() @RuleEngineView('priority-configs') @ApiOperation({ summary: 'List priority configs' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined, - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListPriorityConfigsQueryDto) { + return this.service.findAll(query); } @Get(':id') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 3c7776a27..c818c9f12 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateRateDto } from '../dto/create-rate.dto'; +import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { type AuthUserPayload, resolveAuthUserId, @@ -22,13 +23,8 @@ export class RatesController { @Get() @RuleEngineView('rates') @ApiOperation({ summary: 'List rates' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - status: query['status'], - rateType: query['rateType'], - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListRatesQueryDto) { + return this.service.findAll(query); } @Get('live') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 18c597b38..c36d4fd79 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; @@ -19,16 +20,8 @@ export class ServiceTypesController { @Get() @RuleEngineView('service-types') @ApiOperation({ summary: 'List service types' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined, - search: query['search'], - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - sortBy: query['sortBy'], - sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC', - }); + findAll(@Query() query: ListServiceTypesQueryDto) { + return this.service.findAll(query); } @Post('reorder') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index 40a67c7f5..baec6b785 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; import { ShippingLinesService } from '../services/shipping-lines.service'; @@ -17,12 +18,8 @@ export class ShippingLinesController { @Get() @RuleEngineView('shipping-lines') @ApiOperation({ summary: 'List shipping lines' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListRuleEngineQueryDto) { + return this.service.findAll(query); } @Get(':id') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index c3f0c1472..98c45798e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRulesService } from '../services/weight-limit-rules.service'; @@ -17,13 +18,8 @@ export class WeightLimitRulesController { @Get() @RuleEngineView('weight-limit-rules') @ApiOperation({ summary: 'List weight limit rules' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - tradeDirection: query['tradeDirection'], - containerTypeId: query['containerTypeId'], - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListWeightLimitRulesQueryDto) { + return this.service.findAll(query); } @Get(':id') diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index d18d0b748..40b2764bf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -5,6 +5,7 @@ import { import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; @@ -19,13 +20,8 @@ export class YardsController { @Get() @RuleEngineView('yards') @ApiOperation({ summary: 'List yards' }) - findAll(@Query() query: Record) { - return this.service.findAll({ - isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined, - country: query['country'], - page: query['page'] ? parseInt(query['page'], 10) : undefined, - pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined, - }); + findAll(@Query() query: ListYardsQueryDto) { + return this.service.findAll(query); } @Post('reorder') diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts new file mode 100644 index 000000000..5718b0531 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -0,0 +1,129 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +/** + * Query-string booleans arrive as strings; implicit conversion is disabled + * app-wide, so coerce explicitly. Mirrors the previous controller behaviour + * (`query['flag'] === 'true'`): only the literal "true" is truthy. + */ +const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined => + value === undefined || value === null || value === '' ? undefined : value === true || value === 'true'; + +/** + * Shared list query for rule-engine resources. Every rule-engine list endpoint + * returns the standard `PaginatedResponse` envelope (`items` + `meta`) built by + * `common/utils/pagination.util.ts`; `search` is applied server-side against + * each resource's human-readable columns (see the repository `findPaged`). + */ +export class ListRuleEngineQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by active flag.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isActive?: boolean; +} + +export class ListCargoTypesQueryDto extends ListRuleEngineQueryDto { + @ApiPropertyOptional({ description: 'Filter by director-approval requirement.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ description: 'Filter by parent cargo-type group.' }) + @IsOptional() + @IsUUID() + parentGroupId?: string; + + @ApiPropertyOptional({ enum: ['displayOrder', 'cargoTypeName', 'code', 'createdAt'], default: 'displayOrder' }) + @IsOptional() + @IsIn(['displayOrder', 'cargoTypeName', 'code', 'createdAt']) + sortBy?: string; +} + +export class ListContainerTypesQueryDto extends ListRuleEngineQueryDto { + @ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' }) + @IsOptional() + @IsIn(['displayOrder']) + sortBy?: string; +} + +export class ListPriorityConfigsQueryDto extends ListRuleEngineQueryDto { + @ApiPropertyOptional({ enum: ['WAGON', 'CURRENCY', 'CUSTOMS'] }) + @IsOptional() + @IsIn(['WAGON', 'CURRENCY', 'CUSTOMS']) + type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; + + @ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' }) + @IsOptional() + @IsIn(['displayOrder']) + sortBy?: string; +} + +export class ListServiceTypesQueryDto extends ListRuleEngineQueryDto { + @ApiPropertyOptional({ description: 'Filter by standalone-bookable flag.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + canBeBookedAlone?: boolean; + + @ApiPropertyOptional({ enum: ['displayOrder', 'serviceName', 'code', 'createdAt'], default: 'displayOrder' }) + @IsOptional() + @IsIn(['displayOrder', 'serviceName', 'code', 'createdAt']) + sortBy?: string; +} + +export class ListYardsQueryDto extends ListRuleEngineQueryDto { + @ApiPropertyOptional({ description: 'Filter by yard country.' }) + @IsOptional() + @IsString() + @MaxLength(50) + country?: string; + + @ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' }) + @IsOptional() + @IsIn(['displayOrder']) + sortBy?: string; +} + +export class ListApprovalRulesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + requiresDirectorApproval?: boolean; + + @ApiPropertyOptional({ enum: ['stepOrder'], default: 'stepOrder' }) + @IsOptional() + @IsIn(['stepOrder']) + sortBy?: string; +} + +export class ListRatesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by rate status (DRAFT, PENDING_APPROVAL, LIVE...).' }) + @IsOptional() + @IsString() + @MaxLength(20) + status?: string; + + @ApiPropertyOptional({ description: 'Filter by derived rate type.' }) + @IsOptional() + @IsString() + @MaxLength(50) + rateType?: string; +} + +export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Filter by container type.' }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ description: 'Filter by trade direction (IMPORT/EXPORT/BOTH).' }) + @IsOptional() + @IsString() + @MaxLength(10) + tradeDirection?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts index 95c8d2568..b87cbae4a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/approval-rules.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ApprovalRule } from '../entities/approval-rule.entity'; export interface IApprovalRulesRepository { @@ -6,6 +8,7 @@ export interface IApprovalRulesRepository { findChainForCargo(requiresDirectorApproval: boolean): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ApprovalRule[], number]>; + findPaged(query: ListApprovalRulesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts index d757b6569..c7d31171f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/cargo-types.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { CargoType } from '../entities/cargo-type.entity'; export interface ICargoTypesRepository { @@ -6,6 +8,7 @@ export interface ICargoTypesRepository { findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[CargoType[], number]>; + findPaged(query: ListCargoTypesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts index f8e097309..35c97ac64 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/container-types.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ContainerType } from '../entities/container-type.entity'; export interface IContainerTypesRepository { @@ -6,6 +8,7 @@ export interface IContainerTypesRepository { findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ContainerType[], number]>; + findPaged(query: ListContainerTypesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts index e4ca08234..d1337c8a6 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/priority-configs.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto'; import { PriorityConfig } from '../entities/priority-config.entity'; export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY'); @@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository { findById(id: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[PriorityConfig[], number]>; + findPaged(query: ListPriorityConfigsQueryDto): Promise>; findAllActive(): Promise; create(data: Partial): Promise; update(id: string, data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 96db214c4..be0962942 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { @@ -13,6 +15,7 @@ export interface IRatesRepository { }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; + findPaged(query: ListRatesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts index 49c7f08e3..aeb5cef43 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/service-types.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ServiceType } from '../entities/service-type.entity'; export interface IServiceTypesRepository { @@ -6,6 +8,7 @@ export interface IServiceTypesRepository { findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ServiceType[], number]>; + findPaged(query: ListServiceTypesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts index 88f36933d..6f39da549 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/shipping-lines.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; import { ShippingLine } from '../entities/shipping-line.entity'; export interface IShippingLinesRepository { @@ -6,6 +8,7 @@ export interface IShippingLinesRepository { findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[ShippingLine[], number]>; + findPaged(query: ListRuleEngineQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index 3c175df4e..d08ebfc97 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; export interface IWeightLimitRulesRepository { @@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository { ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; + findPaged(query: ListWeightLimitRulesQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts index 9cfcfd940..75baca5de 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yards.repository.interface.ts @@ -1,4 +1,6 @@ +import { PaginatedResponse } from '@edr/types'; import { FindManyOptions } from 'typeorm'; +import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; import { Yard } from '../entities/yard.entity'; export interface IYardsRepository { @@ -6,6 +8,7 @@ export interface IYardsRepository { findByCode(code: string): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Yard[], number]>; + findPaged(query: ListYardsQueryDto): Promise>; create(data: Partial): Promise; update(id: string, data: Partial): Promise; softDelete(id: string): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts index c77695395..c62ce6724 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/approval-rules.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ApprovalRule } from '../entities/approval-rule.entity'; import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface'; @@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository { return this.repo.findAndCount(options); } + /** + * Paged list in the standard envelope. Chain grouping is preserved: rows are + * grouped by chain (requiresDirectorApproval) first, then step order. + */ + findPaged(query: ListApprovalRulesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('rule') + .orderBy('rule.requiresDirectorApproval', 'ASC') + .addOrderBy('rule.stepOrder', query.sortOrder ?? 'ASC'); + + if (query.requiresDirectorApproval !== undefined) { + qb.andWhere('rule.requiresDirectorApproval = :requiresDirectorApproval', { + requiresDirectorApproval: query.requiresDirectorApproval, + }); + } + if (query.search) { + qb.andWhere( + '(rule.actionLabel ILIKE :search OR rule.requiredRole ILIKE :search OR rule.blocksRole ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 5fba70fe1..8e4be5a3b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface'; @@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (name/code) in the standard envelope. */ + findPaged(query: ListCargoTypesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('cargoType') + .leftJoinAndSelect('cargoType.parent', 'parent') + .orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); + + if (query.isActive !== undefined) { + qb.andWhere('cargoType.isActive = :isActive', { isActive: query.isActive }); + } + if (query.requiresDirectorApproval !== undefined) { + qb.andWhere('cargoType.requiresDirectorApproval = :requiresDirectorApproval', { + requiresDirectorApproval: query.requiresDirectorApproval, + }); + } + if (query.parentGroupId !== undefined) { + qb.andWhere('cargoType.parentGroupId = :parentGroupId', { parentGroupId: query.parentGroupId }); + } + if (query.search) { + qb.andWhere('(cargoType.cargoTypeName ILIKE :search OR cargoType.code ILIKE :search)', { + search: `%${query.search}%`, + }); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index 0e4fb2716..ff5a3f994 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ContainerType } from '../entities/container-type.entity'; import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface'; @@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (label/code) in the standard envelope. */ + findPaged(query: ListContainerTypesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('containerType') + .orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); + + if (query.isActive !== undefined) { + qb.andWhere('containerType.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + qb.andWhere('(containerType.label ILIKE :search OR containerType.code ILIKE :search)', { + search: `%${query.search}%`, + }); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts index d8326b794..909b1d8af 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/priority-configs.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto'; import { PriorityConfig } from '../entities/priority-config.entity'; import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface'; @@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (label/type/currency) in the standard envelope. */ + findPaged(query: ListPriorityConfigsQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('config') + .orderBy(`config.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); + + if (query.type !== undefined) { + qb.andWhere('config.type = :type', { type: query.type }); + } + if (query.isActive !== undefined) { + qb.andWhere('config.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + qb.andWhere( + '(config.label ILIKE :search OR config.type ILIKE :search OR config.currency ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async findAllActive(): Promise { return this.repo.find({ where: { isActive: true }, diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index a7b8e69ab..85bfc783f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { Rate } from '../entities/rate.entity'; import { IRatesRepository } from '../interfaces/rates.repository.interface'; @@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (type/status/unit/currency), newest first. */ + findPaged(query: ListRatesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('rate') + .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); + + if (query.status) { + qb.andWhere('rate.status = :status', { status: query.status }); + } + if (query.rateType) { + qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType }); + } + if (query.search) { + qb.andWhere( + '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts index 5e5f88b0a..bda47e31e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/service-types.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ServiceType } from '../entities/service-type.entity'; import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface'; @@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (name/code/description) in the standard envelope. */ + findPaged(query: ListServiceTypesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('serviceType') + .orderBy(`serviceType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); + + if (query.isActive !== undefined) { + qb.andWhere('serviceType.isActive = :isActive', { isActive: query.isActive }); + } + if (query.canBeBookedAlone !== undefined) { + qb.andWhere('serviceType.canBeBookedAlone = :canBeBookedAlone', { + canBeBookedAlone: query.canBeBookedAlone, + }); + } + if (query.search) { + qb.andWhere( + '(serviceType.serviceName ILIKE :search OR serviceType.code ILIKE :search OR serviceType.description ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts index 521a72b95..2ae163d66 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/shipping-lines.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; import { ShippingLine } from '../entities/shipping-line.entity'; import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface'; @@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (label/code/mappedToCode), ordered by code. */ + findPaged(query: ListRuleEngineQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('line') + .orderBy('line.code', query.sortOrder ?? 'ASC'); + + if (query.isActive !== undefined) { + qb.andWhere('line.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + qb.andWhere( + '(line.label ILIKE :search OR line.code ILIKE :search OR line.mappedToCode ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 7432dfc34..598bdf8bb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface'; @@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { return this.repo.findAndCount(options); } + /** + * Paged list with the container relation loaded, newest first. `search` + * matches the trade direction and the joined container type's label/code. + */ + findPaged(query: ListWeightLimitRulesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('rule') + .leftJoinAndSelect('rule.containerType', 'containerType') + .orderBy('rule.createdAt', query.sortOrder ?? 'DESC'); + + if (query.containerTypeId) { + qb.andWhere('rule.containerTypeId = :containerTypeId', { + containerTypeId: query.containerTypeId, + }); + } + if (query.tradeDirection) { + qb.andWhere('rule.tradeDirection = :tradeDirection', { tradeDirection: query.tradeDirection }); + } + if (query.search) { + qb.andWhere( + '(rule.tradeDirection ILIKE :search OR containerType.label ILIKE :search OR containerType.code ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts index c2f1c62f1..1cb74d9ce 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yards.repository.ts @@ -1,5 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { Injectable } from '@nestjs/common'; import { DataSource, FindManyOptions, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; import { Yard } from '../entities/yard.entity'; import { IYardsRepository } from '../interfaces/yards.repository.interface'; @@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository { return this.repo.findAndCount(options); } + /** Paged list with server-side search (label/code/country) in the standard envelope. */ + findPaged(query: ListYardsQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('yard') + .orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC') + .addOrderBy('yard.label', 'ASC'); + + if (query.isActive !== undefined) { + qb.andWhere('yard.isActive = :isActive', { isActive: query.isActive }); + } + if (query.country) { + qb.andWhere('yard.country = :country', { country: query.country }); + } + if (query.search) { + qb.andWhere( + '(yard.label ILIKE :search OR yard.code ILIKE :search OR yard.country ILIKE :search)', + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } + async create(data: Partial): Promise { const entity = this.repo.create(data); return this.repo.save(entity); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts index 063cc6439..c22677608 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/approval-rules.service.ts @@ -1,5 +1,7 @@ +import { PaginatedResponse } from '@edr/types'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; +import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; import { ApprovalRule } from '../entities/approval-rule.entity'; @@ -17,26 +19,9 @@ export class ApprovalRulesService { private readonly displayOrder: DisplayOrderService, ) {} - /** List approval rules. */ - async findAll(filter: { - requiresDirectorApproval?: boolean; - page?: number; - pageSize?: number; - }): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 10; - const where: Record = {}; - if (filter.requiresDirectorApproval !== undefined) { - where.requiresDirectorApproval = filter.requiresDirectorApproval; - } - - const [data, total] = await this.repository.findAndCount({ - where, - order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List approval rules — standard paginated envelope with server-side search. */ + async findAll(query: ListApprovalRulesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get approval chain for a cargo type flag. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 5470094a5..941d35f2f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -1,7 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; +import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; @@ -19,33 +20,9 @@ export class CargoTypesService { private readonly displayOrder: DisplayOrderService, ) {} - /** List cargo types with pagination and optional filtering. */ - async findAll(filter: { - isActive?: boolean; - requiresDirectorApproval?: boolean; - parentGroupId?: string; - search?: string; - page?: number; - pageSize?: number; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - }): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 10; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval; - if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId; - if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`); - - const [data, total] = await this.repository.findAndCount({ - where, - order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - relations: { parent: true }, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List cargo types — standard paginated envelope with server-side search. */ + async findAll(query: ListCargoTypesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a single cargo type by ID. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index 629bf3023..da641afe0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -1,6 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; +import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; @@ -18,24 +20,9 @@ export class ContainerTypesService { private readonly displayOrder: DisplayOrderService, ) {} - /** List container types with pagination. */ - async findAll(filter: { - isActive?: boolean; - page?: number; - pageSize?: number; - }): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 10; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - - const [data, total] = await this.repository.findAndCount({ - where, - order: { displayOrder: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List container types — standard paginated envelope with server-side search. */ + async findAll(query: ListContainerTypesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a single container type by ID. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 6d7034ad4..560a550b2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -1,5 +1,7 @@ +import { PaginatedResponse } from '@edr/types'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; +import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; import { PriorityConfig } from '../entities/priority-config.entity'; import { @@ -16,25 +18,9 @@ export class PriorityConfigsService { private readonly displayOrder: DisplayOrderService, ) {} - async findAll(filter: { - type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; - isActive?: boolean; - page?: number; - pageSize?: number; - }): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.type !== undefined) where.type = filter.type; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - - const [data, total] = await this.repository.findAndCount({ - where, - order: { displayOrder: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List priority configs — standard paginated envelope with server-side search. */ + async findAll(query: ListPriorityConfigsQueryDto): Promise> { + return this.repository.findPaged(query); } async findById(id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 0027e18c1..2be1cf670 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -5,7 +5,9 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; import { CreateRateDto } from '../dto/create-rate.dto'; +import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; @@ -19,26 +21,9 @@ export class RatesService { private readonly repository: IRatesRepository, ) {} - /** List rates with pagination. */ - async findAll(filter: { - status?: string; - rateType?: string; - page?: number; - pageSize?: number; - }): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.status) where.status = filter.status; - if (filter.rateType) where.rateType = filter.rateType; - - const [data, total] = await this.repository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } }; + /** List rates — standard paginated envelope with server-side search. */ + async findAll(query: ListRatesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Return all currently LIVE rates. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts index 6608749d1..7592a6f76 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/service-types.service.ts @@ -1,7 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ILike } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; +import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; import { ServiceType } from '../entities/service-type.entity'; @@ -19,30 +20,9 @@ export class ServiceTypesService { private readonly displayOrder: DisplayOrderService, ) {} - /** List service types with pagination and optional filtering. */ - async findAll(filter: { - isActive?: boolean; - canBeBookedAlone?: boolean; - search?: string; - page?: number; - pageSize?: number; - sortBy?: string; - sortOrder?: 'ASC' | 'DESC'; - }): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 10; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone; - if (filter.search) where.serviceName = ILike(`%${filter.search}%`); - - const [data, total] = await this.repository.findAndCount({ - where, - order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List service types — standard paginated envelope with server-side search. */ + async findAll(query: ListServiceTypesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a single service type by ID. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts index 4eaa47a26..381b25728 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/shipping-lines.service.ts @@ -1,5 +1,7 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; +import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; import { ShippingLine } from '../entities/shipping-line.entity'; import { @@ -14,24 +16,9 @@ export class ShippingLinesService { private readonly repository: IShippingLinesRepository, ) {} - /** List shipping lines with pagination. */ - async findAll(filter: { - isActive?: boolean; - page?: number; - pageSize?: number; - }): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - - const [data, total] = await this.repository.findAndCount({ - where, - order: { code: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List shipping lines — standard paginated envelope with server-side search. */ + async findAll(query: ListRuleEngineQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a shipping line by ID. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index 44f5332f2..076ebc1ea 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -5,7 +5,9 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; +import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { @@ -20,27 +22,9 @@ export class WeightLimitRulesService { private readonly repository: IWeightLimitRulesRepository, ) {} - /** List weight limit rules with pagination. */ - async findAll(filter: { - containerTypeId?: string; - tradeDirection?: string; - page?: number; - pageSize?: number; - }): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 20; - const where: Record = {}; - if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId; - if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; - - const [data, total] = await this.repository.findAndCount({ - where, - relations: { containerType: true }, - order: { createdAt: 'DESC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List weight limit rules — standard paginated envelope with server-side search. */ + async findAll(query: ListWeightLimitRulesQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a single weight limit rule by ID. */ diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts index 0c95582af..47b1f05bc 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yards.service.ts @@ -1,6 +1,8 @@ +import { PaginatedResponse } from '@edr/types'; import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateYardDto } from '../dto/create-yard.dto'; +import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; import { Yard } from '../entities/yard.entity'; @@ -15,26 +17,9 @@ export class YardsService { private readonly displayOrder: DisplayOrderService, ) {} - /** List yards with pagination. */ - async findAll(filter: { - isActive?: boolean; - country?: string; - page?: number; - pageSize?: number; - }): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> { - const page = filter.page ?? 1; - const pageSize = filter.pageSize ?? 10; - const where: Record = {}; - if (filter.isActive !== undefined) where.isActive = filter.isActive; - if (filter.country) where.country = filter.country; - - const [data, total] = await this.repository.findAndCount({ - where, - order: { displayOrder: 'ASC', label: 'ASC' }, - skip: (page - 1) * pageSize, - take: pageSize, - }); - return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } }; + /** List yards — standard paginated envelope with server-side search. */ + async findAll(query: ListYardsQueryDto): Promise> { + return this.repository.findPaged(query); } /** Get a yard by ID. */ diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 899b302fc..cf87622ab 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -1,5 +1,8 @@ import { BaseEntity } from '@edr/api-common'; -import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; +import { + TrainScheduleStatus as TrainScheduleStatusEnum, + WagonAllocationSnapshot, +} from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; @@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) ruleExportBookingLeadHours?: number | null; + // Frozen wagon plan captured once when the schedule leaves the editable + // DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a + // non-editable schedule read THIS instead of the live wagon↔slot joins, so the + // historical allocation survives the same physical wagons being re-pinned onto + // later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows. + @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) + wagonAllocationSnapshot?: WagonAllocationSnapshot | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 6a8aced53..f32f2094e 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository { }); } + /** + * Light fetch for human-facing labels (notifications): reference, train + * number, departure and the two station names — none of the composition + * graph {@link findByIdWithFullGraph} drags in. + */ + findByIdWithStations(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { originStation: true, destinationStation: true }, + }); + } + async updateStatus( id: string, status: TrainScheduleStatus, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts new file mode 100644 index 000000000..cb2e63223 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.export-space.spec.ts @@ -0,0 +1,140 @@ +import { ConflictException } from '@nestjs/common'; + +import { BookingBatchService } from './booking-batch.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * Export whole-booking single-train gate: an export booking never splits — it + * rides one train whole or is rejected. The report must try every fillable + * train on the day (first full → use the second), and when none fits, say how + * much space is still bookable so the customer knows what he CAN book. + */ +describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => { + const DAY = new Date('2026-07-20T10:00:00Z'); + + const schedule = (id: string) => ({ + id, + status: 'SCHEDULED', + direction: 'EXPORT', + scheduledDepartureDate: DAY, + bookingWindowStatus: 'OPEN', + windowPhase: null, // legacy gate: OPEN alone makes it fillable + }); + + const fullGraph = (id: string) => ({ + id, + originStationId: 'yard-a', + destinationStationId: 'yard-b', + routeId: null, // legacy two-stop pseudo-route — no milestone query + scheduleBookings: [], + trainSet: { + locomotive: { + maxPullWeightTons: 500, + maxTrainLengthMeters: 140, + overageToleranceTons: 0, + overageToleranceMeters: 0, + }, + }, + }); + + const exportBooking = (cargoTons: number) => + ({ + id: 'bk-exp', + freightType: 'BULK', + tradeDirection: 'EXPORT', + scheduledDate: DAY, + originYardId: 'yard-a', + destinationYardId: 'yard-b', + cargoTotalWeightVgm: cargoTons, + bookingContainers: [], + }) as unknown as Booking; + + // A reserved bulk booking heavy enough to exhaust the 500t pull budget. + const heavyReserved = { + id: 'bk-heavy', + freightType: 'BULK', + originYardId: 'yard-a', + destinationYardId: 'yard-b', + cargoTotalWeightVgm: 476, + bookingContainers: [], + } as unknown as Booking; + + let service: BookingBatchService; + let trainSchedulesRepository: { + findAll: jest.Mock; + findByIdWithFullGraph: jest.Mock; + findById: jest.Mock; + }; + let bookingsRepository: { findReservedForSchedule: jest.Mock }; + + beforeEach(() => { + trainSchedulesRepository = { + findAll: jest.fn().mockResolvedValue([]), + findByIdWithFullGraph: jest + .fn() + .mockImplementation(async (id: string) => fullGraph(id)), + findById: jest.fn(), + }; + bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) }; + + // WagonType.find() → [] so representative default dims apply (bulk 60t + // payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null). + const genericRepo = { find: jest.fn().mockResolvedValue([]) }; + const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) }; + + service = new BookingBatchService( + dataSource as never, + bookingsRepository as never, + trainSchedulesRepository as never, + {} as never, // trainScheduleBookingsRepository + {} as never, // notifier + {} as never, // scheduler + {} as never, // trainSchedulingService + {} as never, // billing + {} as never, // bookingWindowGateway + {} as never, // pricingService + ); + }); + + it('uses the other train when the first one is full', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + schedule('train-1'), + schedule('train-2'), + ]); + bookingsRepository.findReservedForSchedule.mockImplementation( + async (id: string) => (id === 'train-1' ? [heavyReserved] : []), + ); + + const report = await service.exportSpaceReport(exportBooking(60)); + + expect(report.scheduleId).toBe('train-2'); + }); + + it('rejects a booking no single train fits and reports the bookable space', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]); + + const report = await service.exportSpaceReport(exportBooking(900)); + + expect(report.scheduleId).toBeNull(); + expect(report.bestAvailable).not.toBeNull(); + expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0); + expect(report.bestAvailable!.cargoTons).toBeLessThan(900); + expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/); + expect(report.fullMessage).toMatch(/single train whole/); + + await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow( + ConflictException, + ); + }); + + it('says no train is accepting bookings when the day has none', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([]); + + const report = await service.exportSpaceReport(exportBooking(60)); + + expect(report.scheduleId).toBeNull(); + expect(report.fullMessage).toBe( + 'No export train is accepting bookings for this day', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index cda6de85f..1c829b4ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -38,8 +38,16 @@ import { BATCH_BOARD_STATUSES, BatchBoardQueryDto, } from './dto/batch-board-query.dto'; -import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; +import { + Freight, + PaginatedResponse, + TrainScheduleStatus as TrainScheduleStatusEnum, +} from "@edr/types"; import { BillingService } from "../billing/billing.service"; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; import { @@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util'; */ type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; +/** + * Result of the export whole-booking single-train space check. `scheduleId` + * is the earliest fillable train that carries the whole booking, or null when + * none can — then `bestAvailable` reports the largest single-train leftover + * in the booking's own units and `fullMessage` is the customer-facing copy. + */ +export interface ExportSpaceReport { + scheduleId: string | null; + trainsForDay: boolean; + corridorMatched: boolean; + need: Capacity; + bestAvailable: { wagons: number; cargoTons: number } | null; + fullMessage: string | null; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -241,15 +264,10 @@ export interface BatchBoardSchedule { bookings: BatchBoardBooking[]; } -/** Paginated batch-board list. `items` (not `data`) — the API response wrapper - * already uses `data`, and the frontend's unwrap() strips one `data` level. */ -export interface BatchBoardListResponse { - items: BatchBoardSchedule[]; - total: number; - page: number; - pageSize: number; - totalPages: number; -} +/** Paginated batch-board list in the shared `{items, meta}` envelope — the API + * response wrapper already uses `data`, and the frontend's unwrap() strips one + * `data` level. */ +export type BatchBoardListResponse = PaginatedResponse; /** * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool @@ -514,6 +532,7 @@ export class BookingBatchService implements OnModuleInit { `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`, ); } + this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated"); } /** Customer paid — delegate to ensurePaidBookingAllocated. */ @@ -542,12 +561,18 @@ export class BookingBatchService implements OnModuleInit { // ---- export FCFS ----------------------------------------------------------- /** - * Export is first-come-first-serve: no window cycle, no priority, no batch. - * Pick the earliest open export train on the booking's corridor/day that still - * fits the booking. Throws ConflictException when every train is full — the - * staff accept fails and no more export bookings are taken. + * Whole-booking single-train space report for an EXPORT booking. Export + * bookings never split — the entire booking must ride ONE train, so the + * report scans every fillable export train on the booking's corridor/day + * (earliest first) for one whose remaining budget fits the whole need. When + * none fits, `bestAvailable` carries the largest single-train leftover + * converted into the booking's own units (base caps, no overage tolerance) + * so the customer can be told exactly how much he COULD book on that day. */ - async pickExportSchedule(booking: Booking, need?: Capacity): Promise { + async exportSpaceReport( + booking: Booking, + need?: Capacity, + ): Promise { if (!booking.scheduledDate) { throw new BadRequestException('Booking has no scheduled date'); } @@ -573,15 +598,19 @@ export class BookingBatchService implements OnModuleInit { (a, b) => a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ); - if (!candidates.length) { - throw new ConflictException( - 'No export train is accepting bookings for this day', - ); - } const wagonDims = await this.loadWagonDims(); const required = need ?? this.needFor(booking, wagonDims); - let corridorMatched = false; + const dims = this.dimsFor(booking, wagonDims); + const report: ExportSpaceReport = { + scheduleId: null, + trainsForDay: candidates.length > 0, + corridorMatched: false, + need: required, + bestAvailable: null, + fullMessage: null, + }; + for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, @@ -592,15 +621,101 @@ export class BookingBatchService implements OnModuleInit { const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg - corridorMatched = true; - if (budget.fits(required, leg)) return schedule.id; + report.corridorMatched = true; + if (budget.fits(required, leg)) { + // Earliest fitting train wins — no need to keep sizing leftovers. + report.scheduleId = schedule.id; + return report; + } + const available = this.bookableWithin(budget.remainingFor(leg), dims); + if ( + !report.bestAvailable || + available.cargoTons > report.bestAvailable.cargoTons || + (available.cargoTons === report.bestAvailable.cargoTons && + available.wagons > report.bestAvailable.wagons) + ) { + report.bestAvailable = available; + } } - if (!corridorMatched) { - throw new ConflictException( - 'No export train is accepting bookings for this day', + + report.fullMessage = this.exportFullMessage(booking, report); + return report; + } + + /** + * Largest booking (in the requester's own wagon-type units) that a single + * train's leftover base capacity could still admit: bounded by free wagon + * slots, free train length, and the locomotive's remaining pull weight + * (gross — each wagon's tare eats into it before any cargo does). + */ + private bookableWithin( + remaining: Capacity, + dims: PerWagonDims, + ): { wagons: number; cargoTons: number } { + const byLength = + dims.lengthMeters > 0 + ? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters) + : Math.floor(Math.max(0, remaining.wagons)); + const maxWagons = Math.max( + 0, + Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength), + ); + let bestTons = 0; + let usableWagons = 0; + for (let w = 1; w <= maxWagons; w++) { + if (w * dims.tareWeightTons > remaining.weightTons) break; + usableWagons = w; + const tons = Math.min( + w * dims.capacityTons, + remaining.weightTons - w * dims.tareWeightTons, + ); + if (tons > bestTons) bestTons = tons; + } + return { + wagons: usableWagons, + cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000), + }; + } + + /** Customer-facing "train is full" copy carrying the bookable leftover. */ + private exportFullMessage(booking: Booking, report: ExportSpaceReport): string { + if (!report.trainsForDay || !report.corridorMatched) { + return 'No export train is accepting bookings for this day'; + } + const best = report.bestAvailable; + const base = + 'Not enough train space — an export booking must ride a single train whole, ' + + 'and no open train on this day can carry it. '; + if (!best || best.wagons <= 0) { + return base + 'No capacity is left on this day — pick another shipment day.'; + } + if (booking.freightType === 'BULK') { + return ( + base + + `The largest remaining space is about ${best.cargoTons} tons ` + + `(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.` ); } - throw new ConflictException('Train is full — no export capacity left for this day'); + return ( + base + + `The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` + + `(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` + + 'reduce the booking or pick another day.' + ); + } + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { + const report = await this.exportSpaceReport(booking, need); + if (report.scheduleId) return report.scheduleId; + throw new ConflictException( + report.fullMessage ?? 'Train is full — no export capacity left for this day', + ); } /** @@ -652,6 +767,7 @@ export class BookingBatchService implements OnModuleInit { if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(scheduleId, 'FULL'); } + this.notifyBoardChanged(scheduleId, 'export_booking_accepted'); } /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ @@ -664,6 +780,9 @@ export class BookingBatchService implements OnModuleInit { `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, ); } + if (unlinked.length > 0) { + this.notifyBoardChanged(scheduleId, "paid_reconciled"); + } } // ---- legacy fill entry point ---------------------------------------------- @@ -700,8 +819,11 @@ export class BookingBatchService implements OnModuleInit { async getBatchBoard( query: BatchBoardQueryDto = {}, ): Promise { - const page = query.page ?? 1; - const pageSize = query.pageSize ?? 12; + // Board cards are heavy (per-schedule booking summaries), so the default + // page is smaller than the toolkit-wide 20. + const { page, pageSize, skip, take } = normalizePagination(query, { + defaultPageSize: 12, + }); // Status filter: any subset of the lifecycle. Omitted = all statuses, so // arrived / cancelled / dispatched schedules stay visible as history. @@ -763,8 +885,8 @@ export class BookingBatchService implements OnModuleInit { route: { originYard: true, destinationYard: true, milestones: { yard: true } }, }, order: { [sortBy]: sortOrder } as never, - skip: (page - 1) * pageSize, - take: pageSize, + skip, + take, }); const wagonDims = await this.loadWagonDims(); @@ -800,13 +922,7 @@ export class BookingBatchService implements OnModuleInit { board.push(this.buildScheduleSummary(s, items)); } - return { - items: board, - total, - page, - pageSize, - totalPages: Math.max(1, Math.ceil(total / pageSize)), - }; + return { items: board, meta: buildPaginationMeta(total, page, pageSize) }; } /** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */ @@ -1048,7 +1164,12 @@ export class BookingBatchService implements OnModuleInit { /** Run wagon-level allocation for all eligible linked bookings on a schedule. */ async runWagonAllocation(scheduleId: string) { - return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + const result = + await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); + if (result.assignedBookingIds.length > 0) { + this.notifyBoardChanged(scheduleId, "wagon_allocation_run"); + } + return result; } /** @@ -1070,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit { maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); - const committed = items.filter( - (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", - ); + // Every booking still targeting this train holds gross weight — including + // PAID ones waiting for wagon allocation (WAITING) and post-dispatch + // catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the + // board's weight the moment customers paid. Only EXPIRED released its hold. + const committed = items.filter((i) => i.state !== "EXPIRED"); const caps = loco ? trainHardCaps({ maxPullWeightTons: Number(loco.maxPullWeightTons), @@ -1218,6 +1341,7 @@ export class BookingBatchService implements OnModuleInit { this.resortPoolByPriority(pool); const units = this.groupConsolidatedPool(pool); let armed = false; + let preempted = false; let reservedThisPass = 0; let commercialReserved = 0; @@ -1256,6 +1380,7 @@ export class BookingBatchService implements OnModuleInit { budget, wagonDims, ); + preempted = true; if (!freed) continue; // still doesn't fit even after preempt } else { // Doesn't fit whole. A split-eligible import booking is offered the part @@ -1304,6 +1429,10 @@ export class BookingBatchService implements OnModuleInit { ); if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL"); if (armed) this.armSettle(scheduleId); + // One push per fill pass (never per booking) — only when rows changed. + if (reservedThisPass > 0 || armed || preempted) { + this.notifyBoardChanged(scheduleId, "batch_fill"); + } void this.triggerWagonAllocation(scheduleId); return commercialReserved; } @@ -1404,8 +1533,13 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); - // Live per-schedule corridor budget + arm flag, in departure order. - const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = []; + // Live per-schedule corridor budget + arm/changed flags, in departure order. + const trains: Array<{ + id: string; + budget: CorridorBudget; + armed: boolean; + changed: boolean; + }> = []; for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); @@ -1419,7 +1553,7 @@ export class BookingBatchService implements OnModuleInit { const limits = await this.capacityLimits(locomotive); await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); - trains.push({ id, budget, armed: false }); + trains.push({ id, budget, armed: false, changed: false }); } if (trains.length === 0) return { scheduleIds, commercialReserved: 0 }; @@ -1492,6 +1626,9 @@ export class BookingBatchService implements OnModuleInit { t.budget, wagonDims, ); + // Preempt may have displaced (expired) victims even when the need + // still doesn't fit — the board must refresh either way. + t.changed = true; if (freed) { target = t; break; @@ -1536,6 +1673,7 @@ export class BookingBatchService implements OnModuleInit { commercialReserved += 1; } target.budget.subtract(need, legOn(target)!); + target.changed = true; reservedThisPass += 1; } catch (err) { this.logger.error( @@ -1553,6 +1691,10 @@ export class BookingBatchService implements OnModuleInit { for (const t of trains) { if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL"); if (t.armed) this.armSettle(t.id); + // One push per touched train per pass (never per booking). `armed` covers + // commercial reserves + partial offers; `changed` covers gov allocations + // and preemption. + if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill"); void this.triggerWagonAllocation(t.id); } @@ -1799,6 +1941,10 @@ export class BookingBatchService implements OnModuleInit { `— payment phase extended for them`, ); } + // Emitted here (not in settleDueReservations/settleBatch, which both wrap + // this) so one settle produces one push, after every allocation/expiry/ + // top-up extension for this schedule has been persisted. + this.notifyBoardChanged(scheduleId, "reservations_settled"); return true; } @@ -1851,6 +1997,21 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Announce that a schedule's batch-board data changed so open boards refetch. + * Called AFTER the state change is persisted; a push failure only logs — it + * must never break the business transaction that triggered it. + */ + private notifyBoardChanged(scheduleId: string, reason: string): void { + try { + this.bookingWindowGateway.emitBatchChanged(scheduleId, reason); + } catch (err) { + this.logger.warn( + `Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + // ---- staff override actions ---------------------------------------------- /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */ @@ -1876,6 +2037,7 @@ export class BookingBatchService implements OnModuleInit { await this.setWindow(booking.trainScheduleId, "FULL"); } void this.triggerWagonAllocation(booking.trainScheduleId!); + this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); } /** @@ -1910,6 +2072,7 @@ export class BookingBatchService implements OnModuleInit { ); } + const sourceScheduleId = booking.trainScheduleId ?? null; await this.dataSource.transaction(async (manager) => { if (booking.trainScheduleId) { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( @@ -1932,6 +2095,11 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); }); + // Both boards changed: the booking left the source train and joined the target. + if (sourceScheduleId && sourceScheduleId !== newScheduleId) { + this.notifyBoardChanged(sourceScheduleId, "booking_moved"); + } + this.notifyBoardChanged(newScheduleId, "booking_moved"); } /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */ @@ -1949,6 +2117,8 @@ export class BookingBatchService implements OnModuleInit { if (topUpReserved > 0) { await this.extendPaymentPhaseForTopUp(freedScheduleId); } + // After the top-up + phase extension so one push carries the final state. + this.notifyBoardChanged(freedScheduleId, "reservation_expired"); } } @@ -1988,10 +2158,12 @@ export class BookingBatchService implements OnModuleInit { .update(booking.id, { trainScheduleId: scheduleId }); booking.trainScheduleId = scheduleId; await this.allocate(scheduleId, booking, 'gov'); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); return; } await this.reserve(booking, scheduleId); this.armSettle(scheduleId); + this.notifyBoardChanged(scheduleId, 'intercity_accepted'); } // ---- mutations ------------------------------------------------------------ @@ -2088,7 +2260,7 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); - this.notifier.secured(booking, reason); + this.notifier.secured(booking, reason, scheduleId); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). @@ -2254,7 +2426,12 @@ export class BookingBatchService implements OnModuleInit { day, ); const leftovers = pool.filter((b) => !b.isGovernment); + // Capture pinned schedules BEFORE expire() clears trainScheduleId, so each + // touched board gets exactly one push at the end of the sweep. + const touchedScheduleIds = new Set(); + if (leftovers.length) touchedScheduleIds.add(scheduleId); for (const booking of leftovers) { + if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.expire(booking, "no-capacity"); } if (leftovers.length) { @@ -2263,6 +2440,9 @@ export class BookingBatchService implements OnModuleInit { `expired ${leftovers.length} waiting booking(s)`, ); } + for (const id of touchedScheduleIds) { + this.notifyBoardChanged(id, "day_pool_expired"); + } return leftovers.length; } @@ -2325,7 +2505,12 @@ export class BookingBatchService implements OnModuleInit { `on ${group.originYardId}->${group.destinationYardId} ${group.day}`, ); } + // Only bookings pinned to a train show on a board — collect their schedules + // and push once per schedule after the sweep (most unaccepted rows are + // unpinned under day-level pooling, so this usually emits nothing). + const touchedScheduleIds = new Set(); for (const booking of unaccepted) { + if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", @@ -2342,6 +2527,9 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`, ); } + for (const id of touchedScheduleIds) { + this.notifyBoardChanged(id, "unaccepted_expired"); + } } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index edd807152..f3d7697f6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -9,6 +9,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() @@ -18,8 +19,37 @@ export class BookingNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + private readonly trainSchedules: TrainSchedulesRepository, ) {} + /** + * Human-readable description of a train schedule for customer messages: + * reference (or train number) + route + departure date. Never leaks a UUID — + * falls back to a generic phrase when the schedule can't be loaded. + */ + private async scheduleLabel(scheduleId?: string | null): Promise { + const fallback = 'your selected train'; + if (!scheduleId) return fallback; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + if (!s) return fallback; + const ref = s.reference ?? s.trainNumber ?? null; + const route = + s.originStation?.label && s.destinationStation?.label + ? ` (${s.originStation.label} → ${s.destinationStation.label})` + : ''; + const departure = s.scheduledDepartureDate + ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}` + : ''; + return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`; + } catch (err) { + this.logger.warn( + `scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, + ); + return fallback; + } + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -127,12 +157,15 @@ export class BookingNotifierService { }); } - secured(b: Booking, reason: 'paid' | 'gov'): void { - const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ - reason === 'gov' ? ' (government)' : '' - }.`; - void this.notifyContact(b, msg, 'ALLOCATED'); - this.inApp(b, 'Wagon allocated', msg); + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { + void (async () => { + const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); + const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${ + reason === 'gov' ? ' (government)' : '' + }.`; + void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); + })(); } expired(b: Booking): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts index 6d706c423..fc47d8a70 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.spec.ts @@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; /** - * applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL - * (both the parent contract row and the booking's denormalized copy) so the split - * remainder can be rebooked. A GENERAL booking is left untouched. + * applySplit split-marking behaviour: the reduced booking is flagged is_split + * and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME + * contracts). The contract kind is NEVER changed — a ONE_TIME contract stays + * ONE_TIME through the split chain. */ -describe('BookingSplitService — applySplit ONE_TIME promotion', () => { +describe('BookingSplitService — applySplit split marking', () => { const bookingId = 'bk-1'; const contractId = 'ct-1'; const offerId = 'of-1'; @@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => { id: bookingId, contractId, contractKind: bookingContractKind, + cargoTotalWeightVgm: 50, }), find: jest.fn().mockResolvedValue([]), softDelete: jest.fn().mockResolvedValue(undefined), @@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => { return { service, bookingRepo, contractRepo }; }; - it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => { - const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); + it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => { + const { service, bookingRepo } = buildService('ONE_TIME'); await service.applySplit(bookingId); expect(bookingRepo.update).toHaveBeenCalledWith( bookingId, - expect.objectContaining({ contractKind: 'GENERAL' }), - ); - expect(contractRepo.update).toHaveBeenCalledWith( - contractId, - expect.objectContaining({ contractKind: 'GENERAL' }), + expect.objectContaining({ + isSplit: true, + preSplitQuantities: { bulkTons: 50 }, + cargoTotalWeightVgm: 30, + wagonsRequired: 3, + }), ); }); - it('leaves a GENERAL booking untouched (no contract promotion)', async () => { + it('never changes the contract kind — ONE_TIME stays ONE_TIME', async () => { + const { service, bookingRepo, contractRepo } = buildService('ONE_TIME'); + + await service.applySplit(bookingId); + + expect(contractRepo.update).not.toHaveBeenCalled(); + expect(bookingRepo.update).not.toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ contractKind: expect.anything() }), + ); + }); + + it('leaves a GENERAL contract untouched too', async () => { const { service, contractRepo } = buildService('GENERAL'); await service.applySplit(bookingId); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index ad0967cf0..9121e4841 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; -import { Contract } from '../contracts/entities/contract.entity'; import { BookingBatchOffer, OfferedLine, @@ -34,11 +33,14 @@ export interface SizedOffer { * GENERAL and ONE_TIME commercial bookings are offered partials: the remainder * returns to the contract's quantity cap (derived live from booking_container * rows, so reducing the lines releases it automatically) and can be rebooked in - * any later window within contract validity. A ONE_TIME contract is promoted to - * GENERAL on split (see applySplit) so its remainder is actually rebookable. - * Once the remainder is rebooked and the cap hits zero, ContractBookingService - * completes the contract (CONTRACT_CLOSED): no further bookings or shipment - * requests, even while validity and a booking window are still open. + * any later window within contract validity. The reduced booking is flagged + * is_split (see applySplit); the contract kind never changes. On a ONE_TIME + * contract a split booking releases the single-active-booking slot, but the + * next booking must take the WHOLE remainder — the split chain is the only way + * a ONE_TIME contract produces multiple bookings. Once the remainder is + * rebooked and the cap hits zero, ContractBookingService completes the + * contract (CONTRACT_CLOSED): no further bookings or shipment requests, even + * while validity and a booking window are still open. */ @Injectable() export class BookingSplitService { @@ -215,11 +217,26 @@ export class BookingSplitService { if (!offer) return; await this.dataSource.transaction(async (manager) => { + // Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME + // contract this is the ledger the outstanding remainder is derived from + // (there is no contract quantity cap to fall back on). + const preSplit = await manager.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: { id: true, cargoTotalWeightVgm: true }, + }); + const preSplitQuantities: { bulkTons?: number; bySize?: Record } = {}; + if (offer.offeredLines?.length) { const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); const lines = await manager.getRepository(BookingContainer).find({ where: { bookingId }, }); + const bySize: Record = {}; + for (const line of lines) { + const size = line.containerSize ?? ''; + bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0); + } + preSplitQuantities.bySize = bySize; for (const line of lines) { const kept = keptByLine.get(line.id); if (!kept) { @@ -251,34 +268,24 @@ export class BookingSplitService { } } } + } else { + preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0); } + // is_split releases the ONE_TIME single-active-booking slot for the + // remainder (whole-remainder-only, enforced at booking creation) and + // switches the contract into remainder-based completion. The contract + // kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the + // split chain. await manager.getRepository(Booking).update(bookingId, { wagonsRequired: offer.offeredWagons, cargoTotalWeightVgm: offer.offeredWeightTons, totalAmount: offer.offeredAmount, pricingBreakdown: offer.offeredPricingBreakdown, + isSplit: true, + preSplitQuantities, } as never); - // A ONE_TIME contract permits a single active booking, which would block the - // split remainder from ever being rebooked. Promote the parent contract (and - // the booking's denormalized copy) to GENERAL so the leftover quantity draws - // down against the cap like any general contract, within the same validity. - const booking = await manager.getRepository(Booking).findOne({ - where: { id: bookingId }, - select: { id: true, contractId: true, contractKind: true }, - }); - if (booking?.contractKind === 'ONE_TIME') { - await manager - .getRepository(Booking) - .update(bookingId, { contractKind: 'GENERAL' } as never); - if (booking.contractId) { - await manager - .getRepository(Contract) - .update(booking.contractId, { contractKind: 'GENERAL' } as never); - } - } - await manager .getRepository(BookingBatchOffer) .update(offer.id, { status: 'APPLIED' }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts index 699471d90..69e60dd3a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.gateway.ts @@ -1,6 +1,7 @@ import { BOOKING_WINDOW_WS_EVENTS, BOOKING_WINDOW_WS_NAMESPACE, + type BatchBoardChangedEvent, type BookingWindowPhaseEvent, } from '@edr/types'; import { Logger } from '@nestjs/common'; @@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection { this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload); } + /** + * Announce that a schedule's batch-board data changed (payment, allocation, + * fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries + * no board data, only the scheduleId; clients holding that board refetch. + */ + emitBatchChanged(scheduleId: string, reason: string): void { + const payload: BatchBoardChangedEvent = { + scheduleId, + reason, + at: new Date().toISOString(), + }; + this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload); + } + private extractToken(socket: Socket): string | undefined { const authToken = socket.handshake.auth?.token as string | undefined; if (authToken) return authToken; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 25a7b1aed..d3405c951 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit { .update(s.id, { docReviewCompletedAt: now }); s.docReviewCompletedAt = now; await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now); + // The batch fill emits only for trains it actually reserved onto — this + // covers the empty-pool case so every open board still refetches. Push + // failures only log; the doc-review completion itself already persisted. + try { + this.gateway.emitBatchChanged(s.id, 'doc_review_completed'); + } catch (err) { + this.logger.warn( + `Batch-board push failed for ${s.id}: ${(err as Error).message}`, + ); + } } const fresh = await this.trainSchedulesRepository.findById(scheduleId); return fresh ?? schedule; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts index 11bc8ceb5..d2a25d7b9 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/batch-board-query.dto.ts @@ -1,15 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { - IsIn, - IsInt, - IsISO8601, - IsOptional, - IsString, - Max, - MaxLength, - Min, -} from 'class-validator'; +import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; export const BATCH_BOARD_STATUSES = [ 'DRAFT', @@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [ ] as const; export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number]; -/** Filters for the batch monitoring board list (import schedules, all statuses). */ -export class BatchBoardQueryDto { - @ApiPropertyOptional({ default: 1, minimum: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number; - - @ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(100) - pageSize?: number; - +/** + * Filters for the batch monitoring board list (import schedules, all statuses). + * `page`/`pageSize`/`search`/`sortOrder` come from the shared + * {@link PaginationQueryDto}; search matches train number, route yards, + * stations, or locomotive code (case-insensitive). + */ +export class BatchBoardQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.', @@ -58,15 +40,6 @@ export class BatchBoardQueryDto { @IsIn(['OPEN', 'FULL', 'CLOSED']) bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED'; - @ApiPropertyOptional({ - description: - 'Case-insensitive match on train number, route yards, stations, or locomotive code.', - }) - @IsOptional() - @IsString() - @MaxLength(120) - search?: string; - @ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' }) @IsOptional() @IsISO8601() @@ -91,9 +64,4 @@ export class BatchBoardQueryDto { @IsOptional() @IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[]) sortBy?: BatchBoardSortField; - - @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) - @IsOptional() - @IsIn(['ASC', 'DESC']) - sortOrder?: 'ASC' | 'DESC'; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts new file mode 100644 index 000000000..7cc6b7115 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/list-train-schedules-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; +import { + TRAIN_SCHEDULE_STATUSES, + TrainScheduleStatus, +} from '../../train-schedules/entities/train-schedule.entity'; + +export const TRAIN_SCHEDULE_SORT_FIELDS = [ + 'createdAt', + 'scheduledDepartureDate', + 'reference', + 'trainNumber', + 'status', +] as const; +export type TrainScheduleSortField = (typeof TRAIN_SCHEDULE_SORT_FIELDS)[number]; + +/** + * Schedules have no freight-type column — the type is DERIVED from the + * bookings aboard (see `resolveScheduleFreightType`): a single kind yields + * CONTAINER or BULK, both kinds yield MIXED, no bookings yield null (never + * matched by this filter). + */ +export const TRAIN_SCHEDULE_FREIGHT_TYPES = ['CONTAINER', 'BULK', 'MIXED'] as const; +export type TrainScheduleFreightType = (typeof TRAIN_SCHEDULE_FREIGHT_TYPES)[number]; + +/** + * Query for the train-schedule list (container/bulk boards). Pagination and + * free-text `search` come from the shared {@link PaginationQueryDto}; search + * matches schedule reference, train number, route yards, stations, or + * locomotive code (case-insensitive). The remaining fields are exact-match + * filters that the search never widens. + */ +export class ListTrainSchedulesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: TRAIN_SCHEDULE_SORT_FIELDS, default: 'createdAt' }) + @IsOptional() + @IsIn(TRAIN_SCHEDULE_SORT_FIELDS as unknown as string[]) + sortBy?: TrainScheduleSortField; + + /** Lifecycle status (exact match). */ + @ApiPropertyOptional({ enum: TRAIN_SCHEDULE_STATUSES }) + @IsOptional() + @IsIn(TRAIN_SCHEDULE_STATUSES as unknown as string[]) + status?: TrainScheduleStatus; + + /** Derived freight type of the bookings aboard (exact match). */ + @ApiPropertyOptional({ enum: TRAIN_SCHEDULE_FREIGHT_TYPES }) + @IsOptional() + @IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[]) + freightType?: TrainScheduleFreightType; + + /** Origin station/yard id (exact match). */ + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + /** Destination station/yard id (exact match). */ + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index fd41d4a29..4196e0972 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -41,6 +41,7 @@ import { import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; +import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; @@ -743,16 +744,16 @@ export class TrainSchedulingController { @Get("container/schedules") @TrainSchedulingView() - @ApiOperation({ summary: "List container train schedules" }) - getContainerTrainSchedules() { - return this.trainSchedulingService.getContainerTrainSchedules(); + @ApiOperation({ summary: "List container train schedules (paginated)" }) + getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { + return this.trainSchedulingService.getContainerTrainSchedules(query); } @Get("bulk/schedules") @TrainSchedulingView() - @ApiOperation({ summary: "List bulk train schedules" }) - getBulkTrainSchedules() { - return this.trainSchedulingService.getContainerTrainSchedules(); + @ApiOperation({ summary: "List bulk train schedules (paginated)" }) + getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) { + return this.trainSchedulingService.getContainerTrainSchedules(query); } @Get("container/schedules/:id") 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 eee880a6b..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 @@ -4,6 +4,7 @@ SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, + WagonAllocationSnapshot, WagonMovementKind, WagonStatus, } from '@edr/types'; @@ -17,8 +18,21 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm'; +import { + DataSource, + EntityManager, + FindOptionsWhere, + ILike, + In, + Not, + QueryFailedError, + Raw, +} from 'typeorm'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -50,6 +64,10 @@ import { CreateContainerTrainScheduleDto } from './dto/create-container-train-sc import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { + ListTrainSchedulesQueryDto, + TrainScheduleFreightType, +} from './dto/list-train-schedules-query.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; @@ -115,6 +133,8 @@ import { computeImportWindowTimes, earliestSchedulableDeparture, eatDay, + eatDayToUtc, + shiftEatDay, } from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { BookingJourneyService } from './booking-journey.service'; @@ -378,6 +398,142 @@ export class TrainSchedulingService { } } + /** + * A schedule GROUP is every schedule sharing an origin, destination, and EAT + * departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ + * group together, since the route entity keys only origin + destination). All + * schedules in a group must run ONE shared booking-window timeline so a + * customer booking on a later-created train is never expired by a sibling + * train's payment window closing on a different clock. + * + * Grouping keys off the columns the schedule already carries — no new schema. + * Callers pass a live `manager` so both the create (inside its transaction) and + * the update paths see uncommitted siblings. + */ + private async findGroupSiblings( + manager: EntityManager, + originStationId: string, + destinationStationId: string, + departure: Date, + excludeScheduleId?: string, + ): Promise { + const day = eatDay(departure); + const dayStart = eatDayToUtc(day, 0); + const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0); + const qb = manager + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .where('s.originStationId = :originStationId', { originStationId }) + .andWhere('s.destinationStationId = :destinationStationId', { destinationStationId }) + .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart }) + .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }); + if (excludeScheduleId) { + qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); + } + return qb.getMany(); + } + + /** + * The window timeline a brand-new schedule must adopt to join its route+day + * group. Returns the canonical open/close times + rule snapshot copied from an + * existing sibling, or null when this is the first schedule in the group (the + * caller then computes its own times as before — nothing changes for the + * single-schedule case). + * + * The anchor is the sibling that best represents where the GROUP currently is + * on its shared clock, so a train created mid-cycle joins the group AT its + * current phase (with the group's exact doc-review / payment deadlines) instead + * of restarting the whole cycle on its own `now`. When the group has advanced + * past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is + * the phase the joiner must adopt to see the same "N minutes left" the group + * already shows. When every sibling is still PRE_WINDOW we pick the + * earliest-opening one (the group's frozen open clock). + */ + private async findGroupWindowAnchor( + manager: EntityManager, + originStationId: string, + destinationStationId: string, + departure: Date, + ): Promise { + const siblings = await this.findGroupSiblings( + manager, + originStationId, + destinationStationId, + departure, + ); + if (siblings.length === 0) return null; + const withWindow = siblings.filter((s) => s.windowOpensAt != null); + if (withWindow.length === 0) return null; + + // A group whose window is live (some sibling has moved past PRE_WINDOW but is + // not yet DONE) — the joiner must land in that same phase on the same clock. + const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT']; + const live = withWindow.filter( + (s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) && + s.windowPhase !== 'PRE_WINDOW', + ); + if (live.length > 0) { + // Most-advanced phase leads; ties broken by earliest open for determinism. + return live.reduce((best, s) => { + const a = PHASE_ORDER.indexOf(s.windowPhase!); + const b = PHASE_ORDER.indexOf(best.windowPhase!); + if (a !== b) return a > b ? s : best; + return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best; + }); + } + + // Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling + // defines the group clock (the one a customer would have seen first). + const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW'); + const pool = pending.length > 0 ? pending : withWindow; + return pool.reduce((earliest, s) => + s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest, + ); + } + + /** + * The full window state an anchor sibling hands down to a schedule JOINING its + * group. Copies not just the open/close times + frozen rule snapshot but the + * anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt, + * paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a + * train created mid-cycle show the EXACT SAME "N minutes left" as the rest of + * the group: it enters directly at the group's current phase with the group's + * shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own + * `now` and stamping its own later `paymentPhaseEndsAt`. + * + * `targetDeparture` is the JOINING schedule's own departure: every time is + * clamped to it so a group whose trains depart at different times of the same + * day never hands an earlier-departing train a deadline that outlives its + * departure (computeImport/ExportWindowTimes clamp to departure at source; this + * preserves that invariant when the anchor departed later). + */ + private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) { + const cap = targetDeparture.getTime(); + const clamp = (d: Date | null | undefined): Date | null => + d == null ? null : d.getTime() > cap ? targetDeparture : d; + return { + // Live phase + its deadlines — a mid-cycle joiner lands here directly. + windowPhase: anchor.windowPhase, + bookingWindowStatus: + anchor.bookingWindowStatus === 'FULL' + ? 'OPEN' + : anchor.bookingWindowStatus, + bookingCycleNo: anchor.bookingCycleNo, + windowOpensAt: clamp(anchor.windowOpensAt), + windowClosesAt: clamp(anchor.windowClosesAt), + docReviewEndsAt: clamp(anchor.docReviewEndsAt), + docReviewCompletedAt: clamp(anchor.docReviewCompletedAt), + paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt), + // Frozen rule snapshot. + ruleWindowOpenHour: anchor.ruleWindowOpenHour, + ruleWindowCloseHour: anchor.ruleWindowCloseHour, + ruleWindowDurationHours: anchor.ruleWindowDurationHours, + ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes, + ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays, + ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours, + }; + } + async getEligibleBookings(query: GetEligibleBookingsDto) { // Day-level pooling: when the wizard targets a schedule, surface the whole // (route, EAT day) pool — not just bookings pre-pinned to that train — by @@ -544,15 +700,53 @@ export class TrainSchedulingService { ); } - await this.dataSource.getRepository(TrainSchedule).update(id, { - windowOpensAt: times.windowOpensAt, - windowClosesAt: times.windowClosesAt, - ...windowRuleSnapshot(merged), - }); + // Route+day grouping (IMPORT/DOMESTIC only): the override applies to the + // WHOLE group — every schedule sharing this origin + destination + EAT + // departure day. They all adopt the SAME rule snapshot and share the SAME + // window open/close timeline (the whole point of grouping). The shared times + // are clamped to each train's OWN departure so a group whose trains depart at + // different times of the same day never hands an earlier-departing sibling a + // window that outlives its departure. Only still-PRE_WINDOW siblings are + // touched — a sibling that has already opened, finalized, or dispatched stays + // frozen on the times its customers were shown and simply drops out of the + // group; the remaining pending trains stay in sync. EXPORT is excluded + // (departure-anchored FCFS window, no cross-expiry), so an export override + // only touches its own schedule. + const ruleFields = windowRuleSnapshot(merged); + const repo = this.dataSource.getRepository(TrainSchedule); + const cap = (d: Date, departure: Date): Date => + d.getTime() > departure.getTime() ? departure : d; + + const targets: Array<{ id: string; departure: Date }> = [ + { id, departure: schedule.scheduledDepartureDate }, + ]; + if (schedule.direction !== 'EXPORT') { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + id, + ); + for (const sib of siblings) { + if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) { + targets.push({ id: sib.id, departure: sib.scheduledDepartureDate }); + } + } + } + + for (const t of targets) { + await repo.update(t.id, { + windowOpensAt: cap(times.windowOpensAt, t.departure), + windowClosesAt: cap(times.windowClosesAt, t.departure), + ...ruleFields, + }); + } this.logger.log( - `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, + `Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` + + `route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`, ); - void this.emitWindowState(id); + for (const t of targets) void this.emitWindowState(t.id); const fresh = await this.trainSchedulesRepository.findById(id); return fresh ?? schedule; @@ -617,14 +811,34 @@ export class TrainSchedulingService { ? computeExportWindowTimes(departure, merged) : computeImportWindowTimes(departure, merged, now); + // Moving the departure moves this train between route+day GROUPS. If the + // destination day already has a group (a sibling on the same origin + + // destination + new EAT day), adopt that group's shared timeline instead of + // the times just derived, so the rescheduled train lines up with the group + // it lands in rather than drifting onto its own clock. Otherwise it keeps its + // own re-derived times and becomes the anchor for that day. EXPORT is + // excluded — its window is anchored to its own departure, not shared. + const anchor = + schedule.direction === 'EXPORT' + ? null + : await this.findGroupWindowAnchor( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + departure, + ); + const windowFields = anchor + ? this.groupWindowFieldsFrom(anchor, departure) + : { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt }; + await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, - windowOpensAt: times.windowOpensAt, - windowClosesAt: times.windowClosesAt, + ...windowFields, }); this.logger.log( `Departure date changed for schedule ${id} → ${departure.toISOString()} ` + - `(window reopens ${times.windowOpensAt.toISOString()})`, + `(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` + + `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`, ); void this.emitWindowState(id); @@ -837,21 +1051,44 @@ export class TrainSchedulingService { // already-open schedule keeps this snapshot, and the batch board draws its // windows from it rather than the live config. const ruleSnapshot = windowRuleSnapshot(windowCfg); - const windowFields = + // Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists + // on this origin + destination + EAT departure day, this new train JOINS + // its group and adopts the group's shared window timeline (open/close + + // frozen rule) verbatim — it does NOT compute its own `now`-based times. + // That keeps every train on the day advancing through the same open/ + // doc-review/payment/close instants, so a booking on one train is never + // expired by a sibling train's payment window closing on a different clock. + // First train in the group falls through to the normal computation. + // + // EXPORT is excluded: an export window is a single FCFS window anchored to + // each train's OWN departure (windowClosesAt = departure) with no + // doc-review/payment phase — so there is no cross-expiry to fix, and two + // export trains departing the same day at different times must keep their + // own departure-anchored windows. + const groupAnchor = direction === 'EXPORT' - ? { - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', - ...ruleSnapshot, - ...computeExportWindowTimes(departure, windowCfg), - } + ? null + : await this.findGroupWindowAnchor( + manager, + route.originYardId, + route.destinationYardId, + departure, + ); + const computedTimes = + direction === 'EXPORT' + ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } : { // IMPORT and DOMESTIC share the import booking-day window cycle. - bookingWindowStatus: 'CLOSED', - windowPhase: 'PRE_WINDOW', ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + const windowFields = { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...(groupAnchor + ? this.groupWindowFieldsFrom(groupAnchor, departure) + : computedTimes), + }; const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) .maxWagonsPerTrain; // Retry past a concurrent insert that grabbed the same S- sequence @@ -899,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(', ')}`, ); @@ -1150,9 +1408,13 @@ export class TrainSchedulingService { const booking = await this.bookingsRepository.findById(bookingId); const schedulingStatus = this.resolvePostUnassignStatus(booking); + // Clear the schedule pointer too: unassign fully detaches the booking from + // this train. Leaving trainScheduleId set glued the booking to a schedule + // that may then be dispatched/cancelled/deleted, orphaning it — the + // assign-bookings parity guard would reject it from every OTHER schedule. await this.bookingsRepository.updateSchedulingFields( bookingId, - { schedulingStatus, wagonsRequired: null }, + { schedulingStatus, wagonsRequired: null, trainScheduleId: null }, manager, ); @@ -1505,12 +1767,34 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( scheduleId, TrainScheduleStatusEnum.Dispatched, - { actualDepartureAt: now, trainNumber }, + { + actualDepartureAt: now, + trainNumber, + // Freeze the wagon plan the moment the train leaves the editable phase. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Dispatched, + now, + ), + }, manager, ); if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); } + // The train is out — every pinned wagon is ASSIGNED to this schedule and + // stays pinned so no other schedule can pick it while it's rolling. + const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + if (dispatchedPhysicalIds.length) { + await manager + .getRepository(Wagon) + .update( + { id: In(dispatchedPhysicalIds) }, + { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, + ); + } for (const sb of schedule.scheduleBookings ?? []) { await this.bookingsRepository.updateSchedulingFields( sb.bookingId, @@ -1587,7 +1871,24 @@ export class TrainSchedulingService { ); } void this.notifyScheduleBookings(schedule, 'dispatched'); - return this.getTrainScheduleById(scheduleId); + + const detail = await this.getTrainScheduleById(scheduleId); + // Surface a compact dispatch confirmation so the caller can toast the "train + // is out" info (train number, departure, wagons committed) without re-deriving it. + const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter( + (slot) => slot.physicalWagonId, + ).length; + return Object.assign(detail, { + dispatchInfo: { + // The real train number was assigned inside the txn — read it back off + // the persisted detail (schedule.trainNumber is the pre-dispatch value). + trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null, + departedAt: now.toISOString(), + wagonsDispatched: dispatchedWagonCount, + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }); } async getImportDjiboutiOperation(scheduleId: string) { @@ -2544,7 +2845,15 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( scheduleId, TrainScheduleStatusEnum.Arrived, - { actualArrivalAt: now }, + { + actualArrivalAt: now, + // Freeze the plan before the wagons below are released to their yards. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Arrived, + now, + ), + }, manager, ); @@ -2641,8 +2950,42 @@ export class TrainSchedulingService { return Object.assign(detail, { warehouseAutomation }); } - async getContainerTrainSchedules() { - const schedules = await this.trainSchedulesRepository.findAll({ + async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) { + const { page, pageSize, skip, take } = normalizePagination(query); + + // Exact-match filters (enum/id semantics). Freight type is derived from + // the bookings aboard — no column to match — so it rides on `id` as an + // EXISTS fragment instead. + const base: FindOptionsWhere = {}; + if (query.status) base.status = query.status; + if (query.originStationId) base.originStationId = query.originStationId; + if (query.destinationStationId) base.destinationStationId = query.destinationStationId; + if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never; + + // Search fans out across every human-recognizable label; each OR variant + // repeats the base filters so the search never widens them. + const term = query.search?.trim(); + let where: FindOptionsWhere | FindOptionsWhere[] = + base; + if (term) { + const like = ILike(`%${term}%`); + where = [ + { ...base, reference: like as never }, + { ...base, trainNumber: like as never }, + { ...base, originStation: { label: like } }, + { ...base, destinationStation: { label: like } }, + { ...base, route: { originYard: { label: like } } }, + { ...base, route: { destinationYard: { label: like } } }, + { ...base, trainSet: { locomotive: { code: like } } }, + ] as FindOptionsWhere[]; + } + + // Newest-created first (the client can re-sort; this is the default order). + const sortBy = query.sortBy ?? 'createdAt'; + const sortOrder = query.sortOrder ?? 'DESC'; + + const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ + where, relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, // Yards carry the route's display name used by mapScheduleListItem; @@ -2652,10 +2995,14 @@ export class TrainSchedulingService { destinationStation: true, scheduleBookings: { booking: true }, }, - // Newest-created first (the client can re-sort; this is the default order). - order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' }, + order: { [sortBy]: sortOrder } as never, + skip, + take, }); - return schedules.map((s) => this.mapScheduleListItem(s)); + return { + items: schedules.map((s) => this.mapScheduleListItem(s)), + meta: buildPaginationMeta(total, page, pageSize), + }; } async getContainerTrainScheduleById(id: string) { @@ -2668,13 +3015,24 @@ export class TrainSchedulingService { throw new NotFoundException(`Train schedule ${id} not found`); } + const now = new Date(); + await this.dataSource.transaction(async (manager) => { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - // Retire the booking window so a canceled schedule never lingers as an - // "open window" in booking-window lists or the legacy batch fill. - { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, + { + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + bookingWindowStatus: 'CLOSED', + windowPhase: 'DONE', + // Freeze the plan before the wagons below are released back to the yard. + wagonAllocationSnapshot: this.buildWagonAllocationSnapshot( + schedule, + TrainScheduleStatusEnum.Cancelled, + now, + ), + }, manager, ); if (schedule.trainSetId) { @@ -2702,14 +3060,23 @@ export class TrainSchedulingService { currentTrainScheduleId: null, trainSetWagonId: null, status: WagonStatus.Available, + // A cancelled train never left — its wagons stay/return at the origin + // yard, free to be re-pinned onto another schedule from there. + currentYardId: schedule.originStationId, }); } } for (const sb of schedule.scheduleBookings ?? []) { const booking = await this.bookingsRepository.findById(sb.bookingId); + // Detach from the cancelled schedule — clear the pointer so the freed + // booking can be assigned to another train. Leaving it set orphans the + // booking against a schedule that is about to be gone. await this.bookingsRepository.updateSchedulingFields( sb.bookingId, - { schedulingStatus: this.resolvePostUnassignStatus(booking) }, + { + schedulingStatus: this.resolvePostUnassignStatus(booking), + trainScheduleId: null, + }, manager, ); } @@ -3241,6 +3608,47 @@ export class TrainSchedulingService { })); } + /** + * Freeze the schedule's live wagon plan into a snapshot. Built from the fully + * hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the + * physical wagons, so the historical allocation survives those wagons being + * re-pinned onto later trains. `capturedStatus` is the status being applied. + */ + private buildWagonAllocationSnapshot( + schedule: TrainSchedule, + capturedStatus: TrainScheduleStatusEnum, + capturedAt: Date, + ): WagonAllocationSnapshot { + const slots = [...(schedule.trainSet?.wagons ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + trainSetWagonId: wagon.id, + physicalWagonId: wagon.physicalWagonId ?? null, + physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonTypeId: wagon.wagonTypeId ?? null, + wagonTypeCode: wagon.wagonType?.code ?? null, + slotStatus: wagon.status ?? null, + boardYardId: wagon.boardYardId ?? null, + alightYardId: wagon.alightYardId ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + loadType: allocation.loadType ?? null, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter((n): n is string => Boolean(n)), + })), + })); + + return { + capturedStatus, + capturedAt: capturedAt.toISOString(), + slots, + }; + } + private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); for (const slot of slots) { @@ -3869,6 +4277,33 @@ export class TrainSchedulingService { }; } + /** + * WHERE fragment matching the DERIVED schedule freight type — the SQL mirror + * of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER / + * BULK = has bookings and every one is that kind; MIXED = both kinds aboard. + * Schedules with no bookings (type null) match nothing. Applied to `id` so + * the list query stays on findAndCount instead of a query-builder rewrite. + */ + private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) { + const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) => + 'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' + + 'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' + + `WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` + + `AND b.freight_type ${cmp} :${param})`; + if (freightType === 'MIXED') { + return Raw( + (alias) => + `${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`, + { ftContainer: 'CONTAINER', ftBulk: 'BULK' }, + ); + } + return Raw( + (alias) => + `${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`, + { ftIs: freightType, ftNot: freightType }, + ); + } + private resolveScheduleFreightType( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ): 'CONTAINER' | 'BULK' | 'MIXED' | null { @@ -4441,6 +4876,20 @@ export class TrainSchedulingService { bulkLoads.map((load) => [load.wagonBookingAllocationId, load]), ); + // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released + // and re-pinned onto later trains — the live wagon↔slot joins no longer + // describe THIS train. If a frozen snapshot was captured at the transition, + // the per-slot wagon number + booking allocations are read from it instead. + const snapshot = schedule.wagonAllocationSnapshot ?? null; + const isWagonAllocationFrozen = Boolean( + snapshot && + schedule.status !== TrainScheduleStatusEnum.Draft && + schedule.status !== TrainScheduleStatusEnum.Scheduled, + ); + const snapshotSlotByTrainSetWagonId = new Map( + (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), + ); + return { id: schedule.id, reference: schedule.reference ?? null, @@ -4523,52 +4972,84 @@ export class TrainSchedulingService { })), wagons: [...(schedule.trainSet.wagons ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) - .map((wagon) => ({ - id: wagon.id, - sequenceNo: wagon.sequenceNo, - capacityTons: roundTons(Number(wagon.capacityTons)), - lengthMeters: roundTons(Number(wagon.lengthMeters)), - assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), - // Empty-wagon weight — the pull limit hauls tare + cargo, so the - // frontend needs it to show the gross train weight. - tareWeightTons: wagon.wagonType - ? roundTons(Number(wagon.wagonType.tareWeightTons)) - : null, - status: wagon.status, - physicalWagonId: wagon.physicalWagonId ?? null, - physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null, - wagonType: wagon.wagonType - ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } - : null, - allocations: - wagon.allocations?.map((allocation) => ({ - id: allocation.id, - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), - loadType: allocation.loadType ?? null, - status: allocation.status, - containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( - (item) => ({ - id: item.id, - containerNumber: item.containerNumber ?? null, - containerTypeId: item.containerTypeId, - grossWeightTons: item.grossWeightTons ?? null, - containerId: item.containerId ?? null, - positionOnWagon: item.positionOnWagon ?? null, - bookingContainerId: item.bookingContainerId ?? null, - }), - ), - bulkLoad: bulkLoadsByAllocation.get(allocation.id) - ? { - id: bulkLoadsByAllocation.get(allocation.id)!.id, - weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, - cargoDescription: - bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, - } - : null, - })) ?? [], - })), + .map((wagon) => { + // Frozen schedules read the wagon number + allocations from the + // snapshot slot; the immutable slot geometry (capacity/type) still + // comes live. Falls back to live if a slot is missing from the snap. + const frozenSlot = isWagonAllocationFrozen + ? snapshotSlotByTrainSetWagonId.get(wagon.id) + : undefined; + return { + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: roundTons(Number(wagon.capacityTons)), + lengthMeters: roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)), + // Empty-wagon weight — the pull limit hauls tare + cargo, so the + // frontend needs it to show the gross train weight. + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: wagon.status, + physicalWagonId: frozenSlot + ? frozenSlot.physicalWagonId + : wagon.physicalWagonId ?? null, + physicalWagonNumber: frozenSlot + ? frozenSlot.physicalWagonNumber + : wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name } + : null, + allocations: frozenSlot + ? frozenSlot.allocations.map((allocation) => ({ + id: null, + bookingId: allocation.bookingId, + bookingReference: allocation.bookingReference, + allocatedWeightTons: roundTons(allocation.allocatedWeightTons), + loadType: allocation.loadType, + status: null, + // Frozen: container detail collapses to the captured numbers; + // per-container geometry isn't re-derivable post-release. + containerItems: allocation.containerNumbers.map((containerNumber) => ({ + id: null, + containerNumber, + containerTypeId: null, + grossWeightTons: null, + containerId: null, + positionOnWagon: null, + bookingContainerId: null, + })), + bulkLoad: null, + })) + : wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)), + loadType: allocation.loadType ?? null, + status: allocation.status, + containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map( + (item) => ({ + id: item.id, + containerNumber: item.containerNumber ?? null, + containerTypeId: item.containerTypeId, + grossWeightTons: item.grossWeightTons ?? null, + containerId: item.containerId ?? null, + positionOnWagon: item.positionOnWagon ?? null, + bookingContainerId: item.bookingContainerId ?? null, + }), + ), + bulkLoad: bulkLoadsByAllocation.get(allocation.id) + ? { + id: bulkLoadsByAllocation.get(allocation.id)!.id, + weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons, + cargoDescription: + bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null, + } + : null, + })) ?? [], + }; + }), } : null, bookings: @@ -4586,6 +5067,11 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], + // True when the wagon plan above is served from the frozen snapshot (schedule + // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge + // it "historical" and skip re-pin affordances. + isWagonAllocationFrozen, + wagonAllocationSnapshot: snapshot, }; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 070a267a4..21f2663b6 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -52,6 +52,12 @@ export class WarehouseInventoryController { return this.inventoryService.arrivalQueue(); } + @Get('zone-occupancy') + @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) + zoneOccupancy(@Query('yardId') yardId?: string) { + return this.inventoryService.zoneOccupancy(yardId); + } + @Post('auto-unload-arrived') @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) autoUnloadArrived() { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index fd4f2057f..7c8edcce0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; @@ -396,6 +397,135 @@ export class WarehouseInventoryService { * but has no customer truck assigned yet, nudge the customer to assign one — with * a deep-link to the booking's truck-assignment card. Fire-and-forget. */ + /** + * Live occupancy per zone: rated capacity vs the weight/items currently held + * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard + * occupancy heatmap. Optionally scoped to one yard. + */ + async zoneOccupancy(yardId?: string): Promise< + Array<{ + id: string; + name: string; + code: string; + type: string; + yardId: string; + capacityWeight: number | null; + capacityContainers: number | null; + usedWeight: number; + usedItems: number; + occupancyPct: number | null; + }> + > { + const rows: Array<{ + id: string; + name: string; + code: string; + type: string; + yardId: string; + capacityWeight: string | null; + capacityContainers: number | null; + usedWeight: number; + usedItems: number; + }> = await this.dataSource.query( + `SELECT z.id, + z.name, + z.code, + z.type, + z.yard_id AS "yardId", + z.capacity_weight AS "capacityWeight", + z.capacity_containers AS "capacityContainers", + COALESCE(SUM(inv.weight) FILTER ( + WHERE inv.deleted_at IS NULL + AND inv.status NOT IN ('DELIVERED', 'DISPATCHED') + ), 0)::float8 AS "usedWeight", + COALESCE(COUNT(inv.id) FILTER ( + WHERE inv.deleted_at IS NULL + AND inv.status NOT IN ('DELIVERED', 'DISPATCHED') + ), 0)::int AS "usedItems" + FROM freight.warehouse_zones z + LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id + WHERE z.is_active = true + AND z.deleted_at IS NULL + AND ($1::uuid IS NULL OR z.yard_id = $1) + GROUP BY z.id + ORDER BY z.name`, + [yardId ?? null], + ); + + return rows.map((r) => { + const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; + const byWeight = + capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; + const byItems = + r.capacityContainers && r.capacityContainers > 0 + ? (r.usedItems / r.capacityContainers) * 100 + : null; + // Prefer container-count occupancy (unit-consistent). Weight capacity is + // tonnes while inventory weight is kg, so weight% is only a rough fallback. + const pct = byItems ?? byWeight; + return { + id: r.id, + name: r.name, + code: r.code, + type: r.type, + yardId: r.yardId, + capacityWeight: capWeight, + capacityContainers: r.capacityContainers, + usedWeight: r.usedWeight, + usedItems: r.usedItems, + occupancyPct: pct == null ? null : Math.round(pct * 10) / 10, + }; + }); + } + + /** + * Recurring nudge: keep reminding self-haul IMPORT customers to assign a + * collection truck while their goods are still in the warehouse + * (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is + * assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED). + */ + @Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' }) + async remindImportTruckAssignment(): Promise { + try { + const rows: Array<{ + bookingId: string; + companyId: string | null; + reference: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT b.id AS "bookingId", + b.company_id AS "companyId", + b.reference + FROM freight.warehouse_inventory inv + JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + WHERE inv.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND b.trade_direction = 'IMPORT' + AND b.customer_truck_assigned_at IS NULL + AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`, + ); + if (!rows.length) return; + this.logger.log( + `Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`, + ); + for (const row of rows) { + await this.notifyTruckAssignmentNeeded( + { + companyId: row.companyId, + reference: row.reference, + hasFirstMile: false, + hasLastMile: false, + customerTruckAssignedAt: null, + }, + row.bookingId, + ); + } + } catch (err) { + this.logger.warn( + `Import truck-assignment reminder tick failed: ${(err as Error).message}`, + ); + } + } + private async notifyTruckAssignmentNeeded(booking: { companyId?: string | null; reference?: string | null; diff --git a/apps/edr-freight-api/src/scripts/seed-dropdown-settings.ts b/apps/edr-freight-api/src/scripts/seed-dropdown-settings.ts new file mode 100644 index 000000000..a8df3cc71 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-dropdown-settings.ts @@ -0,0 +1,23 @@ +import { AppDataSource } from "../data-source"; +import { DropdownSettingsSeeder } from "../seed/dropdown-settings.seeder"; + +/** + * Seed the dropdown settings catalog (codes/labels only, no options) into an + * EMPTY dropdown_settings table; skips entirely if any rows exist. Run on + * demand: + * pnpm --filter @edr/freight-api seed:dropdown-settings + */ +async function run() { + await AppDataSource.initialize(); + try { + const seeder = new DropdownSettingsSeeder(AppDataSource); + await seeder.run(); + } finally { + await AppDataSource.destroy(); + } +} + +run().catch((error) => { + console.error("Failed to seed dropdown settings:", error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts new file mode 100644 index 000000000..4df5d9028 --- /dev/null +++ b/apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { + DropdownSetting, + DropdownSettingMeta, +} from "../modules/dropdown-settings/entities/dropdown-setting.entity"; + +interface DefaultDropdownSetting { + code: string; + label: string; + description: string; + multiple: boolean; + meta?: DropdownSettingMeta | null; +} + +/** + * Known dropdown settings, seeded as empty catalogs (no options). The options + * are managed by the admin from the backoffice Dropdown Settings editor. + */ +const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [ + { + code: "stations_ter", + label: "Stations TER", + description: + "Temporary freight station list used by booking origin and destination yards.", + multiple: false, + meta: { searchable: true, clearable: true, version: "temporary" }, + }, + { + code: "general_contract_period", + label: "General Contract Period (months)", + description: + "How many months a general contract stays open for ordering after activation.", + multiple: false, + }, + { + code: "contract_validity_periods", + label: "Contract Validity Periods (days)", + description: + "Validity durations (in days) a staff can choose when accepting a submitted contract.", + multiple: false, + }, + { + code: "ro_vessel_min_days", + label: "RO vessel minimum lead time (days)", + description: + "Minimum days between today and the vessel departure date on an export Release Order.", + multiple: false, + }, +]; + +@Injectable() +export class DropdownSettingsSeeder { + private readonly logger = new Logger(DropdownSettingsSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + const settingRepository = this.dataSource.getRepository(DropdownSetting); + + // Seed only into an empty table: any existing rows (including + // soft-deleted ones, which would still conflict on the unique `code`) + // mean the data is admin-managed, so leave it untouched. + const existing = await settingRepository.count({ withDeleted: true }); + if (existing > 0) { + this.logger.log( + `dropdown_settings already has ${existing} rows — skipping seed`, + ); + return; + } + + // Insert setting rows only — no DropdownOption rows. Options start empty + // and are configured by the admin from the backoffice editor. + await settingRepository.insert( + DEFAULT_DROPDOWN_SETTINGS.map((setting) => ({ + code: setting.code, + label: setting.label, + description: setting.description, + multiple: setting.multiple, + meta: setting.meta ?? null, + })), + ); + + this.logger.log( + `Seeded ${DEFAULT_DROPDOWN_SETTINGS.length} dropdown settings with empty options`, + ); + } +} diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 13759ddb3..d65b2e2d8 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -1,7 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { DataSource } from "typeorm"; -import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; interface OnboardingField { @@ -576,88 +575,66 @@ export class FileUploadSettingsSeeder { constructor(private readonly dataSource: DataSource) { } async run() { - await this.dataSource.transaction(async (manager) => { - const settingRepository = manager.getRepository(FileUploadSetting); - const fieldRepository = manager.getRepository(FileUploadField); + const settingRepository = this.dataSource.getRepository(FileUploadSetting); - const allSettings: Array< - OnboardingDocumentSetting & { description: string } - > = [ - ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ - ...s, - description: COMPANY_ONBOARDING_DESCRIPTION, - })), - ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: CLEARANCE_DESCRIPTION, - })), - ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", - })), - ...SELF_CLEARANCE_SETTINGS.map((s) => ({ - ...s, - description: - "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", - })), - ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ - ...s, - description: - "Commercial/framework documents attached at contract submission.", - })), - ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ - ...s, - description: - "Documents uploaded against a driver profile (license, ID, contracts, etc.).", - })), - ]; + // Seed only into an empty table: any existing rows (including + // soft-deleted ones, which would still conflict on the unique `code`) + // mean the data is admin-managed, so leave it untouched. + const existing = await settingRepository.count({ withDeleted: true }); + if (existing > 0) { + this.logger.log( + `file_upload_settings already has ${existing} rows — skipping seed`, + ); + return; + } - for (const documentSetting of allSettings) { - await settingRepository.upsert( - { - code: documentSetting.code, - label: documentSetting.label, - description: documentSetting.description, - entity: documentSetting.entity, - }, - { - conflictPaths: { code: true }, - }, - ); + const allSettings: Array< + OnboardingDocumentSetting & { description: string } + > = [ + ...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({ + ...s, + description: COMPANY_ONBOARDING_DESCRIPTION, + })), + ...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: CLEARANCE_DESCRIPTION, + })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...SELF_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), + ...DRIVER_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Documents uploaded against a driver profile (license, ID, contracts, etc.).", + })), + ]; - const setting = await settingRepository.findOne({ - where: { code: documentSetting.code }, - select: { id: true, code: true }, - }); - - if (!setting) { - throw new Error( - `file_upload_setting_seed_failed:${documentSetting.code}`, - ); - } - - await fieldRepository.delete({ settingId: setting.id }); - - await fieldRepository.insert( - documentSetting.fields.map((field, index) => ({ - settingId: setting.id, - fileKey: field.fileKey, - fileLabel: field.fileLabel, - helpText: field.helpText, - isRequired: field.isRequired, - isMultiple: field.isMultiple, - maxFiles: field.maxFiles, - allowedExtensions: [...field.allowedExtensions], - maxSizeMb: field.maxSizeMb, - displayOrder: field.displayOrder ?? index + 1, - })), - ); - } - }); + // Insert setting rows only — no FileUploadField rows. Fields start empty + // and are configured from the backoffice file-settings editor; the field + // definitions above are kept as reference defaults. + await settingRepository.insert( + allSettings.map((documentSetting) => ({ + code: documentSetting.code, + label: documentSetting.label, + description: documentSetting.description, + entity: documentSetting.entity, + })), + ); this.logger.log( - "Ensured company onboarding + booking clearance file upload settings", + `Seeded ${allSettings.length} file upload settings with empty fields`, ); } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 18d382933..18e291872 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; @@ -126,6 +127,7 @@ import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage"; import { HealthCheck } from "./features/health/HealthCheck"; import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; +import SetPassword from "./shared/components/SetPassword"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -152,6 +154,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + // Operations hub: clearance-document review for contracts WITHOUT + // customs clearing (contract-level for one-time, per-booking for general). + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "Customers", href: "/dashboard/customers", @@ -655,6 +665,10 @@ const App = () => { } /> {/* } /> */} + } + /> } /> } /> @@ -788,6 +802,18 @@ const App = () => { } /> + {/* Operations hub: clearance documents for non-customs contracts — + Contracts tab (contract-level) + General tab (per-booking). */} + + + + } + /> {/* GL (Path B) contract clearance review hub */} computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index a520c35e5..223b357c1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -1,13 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from "react"; import { - useNavigate, - useParams, - useSearchParams, -} from "react-router-dom"; + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Alert, - Badge, Box, Button, Center, @@ -16,7 +17,6 @@ import { Group, Loader, Modal, - NumberInput, Paper, Select, Stack, @@ -25,6 +25,7 @@ import { Textarea, TextInput, ThemeIcon, + Title, } from "@mantine/core"; import { AlertCircle, @@ -49,6 +50,7 @@ import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; import { contractsService } from "@/services/contracts.service"; import { + useContractCapacity, useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; @@ -76,11 +78,27 @@ const EAT_TZ = "Africa/Addis_Ababa"; // Same rule the customer portal shipment form enforces. const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; +/** + * Every quantity on this form is a non-negative magnitude. A native number + * input's `min` only constrains its stepper, so swallow the minus key before it + * can put a negative into the field at all. Same guard as the portal form. + */ +const blockNegative = (event: KeyboardEvent) => { + if (event.key === "-") event.preventDefault(); +}; + interface UnitErrors { containerNumber?: string; vgmTons?: string; } +interface LineErrors { + quantity?: string; + hazardousQuantity?: string; + reeferQuantity?: string; + units?: string; +} + interface BulkErrors { quantity?: string; hazardous?: string; @@ -106,32 +124,37 @@ function fmtWindowOpensAt(iso: string): string { interface UnitDraft { containerNumber: string; sealNumber: string; - vgmTons: number | string; - /** Per-unit flags — the line's hazardous/reefer counts are derived from these. */ - hazardous: boolean; - reefer: boolean; + vgmTons: string; } +/** Mirrors the portal shipment form's container line: line-level quantity + + * hazardous/reefer counts with per-unit number/seal/VGM details. */ interface ContainerLineDraft { containerSize: string; + quantity: string; + hazardousQuantity: string; + reeferQuantity: string; units: UnitDraft[]; } -interface BulkLineDraft { - cargoTypeId: string; - cargoWeightTons: number | string; - itemCount: number | string; - hazardousQuantity: number | string; - reeferQuantity: number | string; +interface BulkDraft { + cargoWeightTons: string; + itemCount: string; + hazardousQuantity: string; + reeferQuantity: string; } function emptyUnit(): UnitDraft { + return { containerNumber: "", sealNumber: "", vgmTons: "" }; +} + +function emptyLine(size: string): ContainerLineDraft { return { - containerNumber: "", - sealNumber: "", - vgmTons: "", - hazardous: false, - reefer: false, + containerSize: size, + quantity: "1", + hazardousQuantity: "0", + reeferQuantity: "0", + units: [emptyUnit()], }; } @@ -182,12 +205,14 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); - // Same window-gating the customer sees: GL may only create a booking while a - // booking window is OPEN for one of the contract's routes. + // Same window-gating the customer sees: booking is only allowed while a + // window is OPEN for one of the contract's routes. Intercity contracts are + // never window-gated — the shipment rides a passing train staff pick later. const contractId = contract?.id ?? id; const { data: bookingWindows, isLoading: windowsLoading } = useQuery({ ...api.trainScheduling.contractBookingWindows.queryOptions({ input: { contractId: contractId ?? "" }, + refetchInterval: 60_000, }), enabled: Boolean(contractId), }); @@ -215,7 +240,12 @@ export default function GlCreateBookingForm() { const [contractRouteId, setContractRouteId] = useState(null); const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); - const [bulkLines, setBulkLines] = useState([]); + const [bulk, setBulk] = useState({ + cargoWeightTons: "", + itemCount: "", + hazardousQuantity: "0", + reeferQuantity: "0", + }); const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); @@ -231,12 +261,68 @@ export default function GlCreateBookingForm() { }, [contract]); const isContainer = contract?.freightType === "CONTAINER"; + // Intercity shipments ride a passing import/export train staff pick at + // finalize time — no shipment day is chosen and no window gate applies. + const isIntercity = contract?.tradeDirection === "DOMESTIC"; + + // ONE_TIME split-remainder mode: a previous booking on this contract was + // split on train capacity, so the capacity endpoint reports the outstanding + // remainder — the EXACT quantity this booking must take (API-enforced). + const { data: splitCapacityLines = [] } = useContractCapacity( + contract?.contractKind === "ONE_TIME" ? contract?.id : undefined, + ); + const remainderLines = useMemo( + () => + contract?.contractKind === "ONE_TIME" + ? splitCapacityLines.filter((l) => (l.remaining ?? 0) > 0) + : [], + [contract?.contractKind, splitCapacityLines], + ); + const remainderMode = remainderLines.length > 0; + + // Bulk: prefill the exact outstanding remainder once — a different amount + // would be rejected at create anyway. + const remainderSeededRef = useRef(false); + useEffect(() => { + if (!remainderMode || isContainer || remainderSeededRef.current) return; + remainderSeededRef.current = true; + setBulk((b) => + b.cargoWeightTons || b.itemCount + ? b + : { ...b, cargoWeightTons: String(remainderLines[0].remaining) }, + ); + }, [remainderMode, isContainer, remainderLines]); + + const remainderNotice = remainderMode ? ( + } + title="This booking must take the whole split remainder" + > + + The previous booking on this one-time contract was split — the paid + part is shipping and the rest returned to the contract. This booking + must cover exactly the remaining{" "} + {remainderLines + .map((l) => + isContainer + ? `${l.remaining} × ${l.containerSize ?? "container"}` + : `${l.remaining} tons`, + ) + .join(", ")} + . A different quantity will be rejected, and no other booking can be + created on this contract. + + + ) : null; const routes = useMemo( () => [...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder), [contract?.routes], ); - const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1; + const multiRoute = routes.length > 1; const containerSizes = useMemo(() => { const sizes = new Set(); @@ -246,19 +332,6 @@ export default function GlCreateBookingForm() { return [...sizes]; }, [contract?.cargoScope]); - const bulkCargoOptions = useMemo(() => { - const seen = new Map(); - (contract?.cargoScope ?? []).forEach((s) => { - if (s.containerSize || !s.cargoTypeId) return; - if (!seen.has(s.cargoTypeId)) { - seen.set(s.cargoTypeId, s.cargoFreeText?.trim() || s.cargoTypeId); - } - }); - return [...seen.entries()].map(([value, label]) => ({ value, label })); - }, [contract?.cargoScope]); - - const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? ""; - useEffect(() => { if (!bookingRequest || prefilled) return; setPrefilled(true); @@ -267,81 +340,53 @@ export default function GlCreateBookingForm() { setContainerLines( lines.containers.map((c) => ({ containerSize: c.containerSize, - // The request carries counts; pre-toggle the first N units so GL sees - // the customer's declared hazardous/reefer split and can adjust it. - units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({ - ...emptyUnit(), - hazardous: i < Number(c.hazardousQuantity ?? 0), - reefer: i < Number(c.reeferQuantity ?? 0), - })), + quantity: String(Math.max(1, c.quantity)), + hazardousQuantity: String(c.hazardousQuantity ?? 0), + reeferQuantity: String(c.reeferQuantity ?? 0), + units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit), })), ); } else if (lines.bulk) { - setBulkLines([ - { - cargoTypeId: lines.bulk.cargoTypeId ?? defaultBulkCargoTypeId, - cargoWeightTons: lines.bulk.cargoWeightTons ?? "", - itemCount: lines.bulk.itemCount ?? "", - hazardousQuantity: lines.bulk.hazardousQuantity ?? "0", - reeferQuantity: "", - }, - ]); + setBulk({ + cargoWeightTons: + lines.bulk.cargoWeightTons != null + ? String(lines.bulk.cargoWeightTons) + : "", + itemCount: + lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "", + hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0), + reeferQuantity: "0", + }); } if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); - }, [bookingRequest, prefilled, defaultBulkCargoTypeId]); + }, [bookingRequest, prefilled]); + // Seed one shipment line per contracted size exactly once — same seeding the + // portal form does. Subsequent renders reuse the lines. useEffect(() => { if (!contract || prefilled || seededRef.current) return; seededRef.current = true; if (isContainer && containerSizes.length > 0 && containerLines.length === 0) { - setContainerLines( - containerSizes.map((size) => ({ - containerSize: size, - units: [emptyUnit()], - })), - ); - } else if (!isContainer && bulkLines.length === 0) { - setBulkLines([ - { - cargoTypeId: defaultBulkCargoTypeId, - cargoWeightTons: "", - itemCount: "", - hazardousQuantity: "0", - reeferQuantity: "0", - }, - ]); + setContainerLines(containerSizes.map(emptyLine)); } - }, [ - contract, - prefilled, - isContainer, - containerSizes, - containerLines.length, - bulkLines.length, - defaultBulkCargoTypeId, - ]); + }, [contract, prefilled, isContainer, containerSizes, containerLines.length]); const quantities: GlShipmentQuantities = useMemo( () => ({ isContainer: Boolean(isContainer), containers: containerLines.map((l) => ({ containerSize: l.containerSize, - quantity: l.units.length, - hazardousQuantity: l.units.filter((u) => u.hazardous).length, - reeferQuantity: l.units.filter((u) => u.reefer).length, + quantity: Number(l.quantity || 0), + hazardousQuantity: Number(l.hazardousQuantity || 0), + reeferQuantity: Number(l.reeferQuantity || 0), })), - bulkQuantity: bulkLines.reduce( - (s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0), - 0, - ), - bulkHazardousQuantity: bulkLines.reduce( - (s, l) => s + Number(l.hazardousQuantity || 0), - 0, - ), + bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0), + bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0), + bulkReeferQuantity: Number(bulk.reeferQuantity || 0), }), - [isContainer, containerLines, bulkLines], + [isContainer, containerLines, bulk], ); const priceTotal = useMemo( @@ -354,6 +399,8 @@ export default function GlCreateBookingForm() { [routes, contractRouteId], ); + // Read the cargo entered above so the day list reflects what can actually be + // shipped (matching wagons + open train capacity) — portal parity. const cargoQuery = useMemo(() => { if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId) return null; @@ -361,7 +408,7 @@ export default function GlCreateBookingForm() { const containers = containerLines .map((l) => ({ containerSize: l.containerSize, - quantity: l.units.length, + quantity: Number(l.quantity || 0), })) .filter((c) => c.quantity >= 1); if (containers.length === 0) return null; @@ -372,10 +419,7 @@ export default function GlCreateBookingForm() { containers, }; } - const tons = bulkLines.reduce( - (s, l) => s + Number(l.cargoWeightTons || 0), - 0, - ); + const tons = Number(bulk.cargoWeightTons || 0); if (tons <= 0) return null; return { originYardId: selectedRoute.originYardId, @@ -386,15 +430,16 @@ export default function GlCreateBookingForm() { ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - }, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]); + }, [selectedRoute, isContainer, containerLines, bulk.cargoWeightTons, contract?.pricingBreakdown]); const { data: availableDays, isLoading: daysLoading } = useQuery({ ...api.trainScheduling.availableDaysForCargo.queryOptions({ input: cargoQuery ?? { freightType: "BULK" as const }, }), - enabled: cargoQuery !== null, + enabled: cargoQuery !== null && !isIntercity, }); + // Keep the units array length in sync with the entered quantity. const syncUnits = (lineIdx: number, qty: number) => { setContainerLines((prev) => prev.map((line, i) => { @@ -423,14 +468,11 @@ export default function GlCreateBookingForm() { ), }); - const patchBulk = (idx: number, patch: Partial) => - setBulkLines((prev) => - prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)), - ); - - // Same client-side validation as the customer portal shipment form: ISO - // container numbers (unique within the shipment) and a positive VGM per unit; - // bulk needs a positive quantity with hazardous/reefer portions bounded by it. + // Same client-side validation as the customer portal shipment form + // (new-shipment-form/schema.ts): ISO container numbers unique within the + // shipment, positive VGM per unit, hazardous/reefer counts bounded by the + // line quantity; bulk needs a positive quantity with hazardous/reefer + // portions bounded by it. Errors only show after a submit attempt. const [showErrors, setShowErrors] = useState(false); // Excel import: one row per container. All-or-nothing — a file with any bad @@ -460,21 +502,17 @@ export default function GlCreateBookingForm() { containerSizes.map((size) => { const imported = rows.filter((r) => r.containerSize === size); if (imported.length === 0) { - return ( - prev.find((l) => l.containerSize === size) ?? { - containerSize: size, - units: [emptyUnit()], - } - ); + return prev.find((l) => l.containerSize === size) ?? emptyLine(size); } return { containerSize: size, + quantity: String(imported.length), + hazardousQuantity: String(imported.filter((r) => r.hazardous).length), + reeferQuantity: String(imported.filter((r) => r.reefer).length), units: imported.map((r) => ({ containerNumber: r.containerNumber, sealNumber: r.sealNumber, - vgmTons: r.vgmTons, - hazardous: r.hazardous, - reefer: r.reefer, + vgmTons: String(r.vgmTons), })), }; }), @@ -507,7 +545,7 @@ export default function GlCreateBookingForm() { errs.containerNumber = "Duplicate container number in this shipment."; } const vgm = Number(u.vgmTons); - if (String(u.vgmTons).trim() === "" || Number.isNaN(vgm) || vgm <= 0) { + if (u.vgmTons.trim() === "" || Number.isNaN(vgm) || vgm <= 0) { errs.vgmTons = "Enter a valid VGM."; } return errs; @@ -515,51 +553,94 @@ export default function GlCreateBookingForm() { ); }, [isContainer, containerLines]); - const bulkErrors = useMemo(() => { - if (isContainer) return []; - return bulkLines.map((line) => { - const errs: BulkErrors = {}; - const qty = Number(line.cargoWeightTons || line.itemCount || 0); - if (Number.isNaN(qty) || qty <= 0) { - errs.quantity = "Enter a quantity greater than 0."; + const lineErrors = useMemo(() => { + if (!isContainer || !contract) return []; + return containerLines.map((line) => { + const errs: LineErrors = {}; + const qty = Number(line.quantity || 0); + if (line.quantity.trim() === "") { + errs.quantity = "Quantity is required."; + } else if (Number.isNaN(qty) || qty < 1) { + errs.quantity = "At least 1."; + } else if (line.units.length < qty) { + errs.units = `Enter details for all ${qty} container(s).`; } - const h = Number(line.hazardousQuantity || 0); - if (Number.isNaN(h) || h < 0) { - errs.hazardous = "Enter a valid hazardous quantity."; - } else if (qty > 0 && h > qty) { - errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; + if (contract.isHazardous) { + const h = Number(line.hazardousQuantity || 0); + if (Number.isNaN(h) || h < 0) { + errs.hazardousQuantity = "Enter a valid hazardous quantity."; + } else if (h > qty) { + errs.hazardousQuantity = `Can't exceed the ${qty} container(s) in this line.`; + } } - const r = Number(line.reeferQuantity || 0); - if (Number.isNaN(r) || r < 0) { - errs.reefer = "Enter a valid refrigerated quantity."; - } else if (qty > 0 && r > qty) { - errs.reefer = `Can't exceed the cargo quantity (${qty}).`; + if (contract.isReefer) { + const r = Number(line.reeferQuantity || 0); + if (Number.isNaN(r) || r < 0) { + errs.reeferQuantity = "Enter a valid refrigerated quantity."; + } else if (r > qty) { + errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`; + } } return errs; }); - }, [isContainer, bulkLines]); + }, [isContainer, contract, containerLines]); + + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; + + const bulkErrors = useMemo(() => { + if (isContainer) return {}; + const errs: BulkErrors = {}; + const qty = + bulkUom === "PER_ITEM" + ? Number(bulk.itemCount || 0) + : Number(bulk.cargoWeightTons || 0); + if (Number.isNaN(qty) || qty <= 0) { + errs.quantity = "Enter a quantity greater than 0."; + } + const h = Number(bulk.hazardousQuantity || 0); + if (Number.isNaN(h) || h < 0) { + errs.hazardous = "Enter a valid hazardous quantity."; + } else if (qty > 0 && h > qty) { + errs.hazardous = `Can't exceed the cargo quantity (${qty}).`; + } + const r = Number(bulk.reeferQuantity || 0); + if (Number.isNaN(r) || r < 0) { + errs.reefer = "Enter a valid refrigerated quantity."; + } else if (qty > 0 && r > qty) { + errs.reefer = `Can't exceed the cargo quantity (${qty}).`; + } + return errs; + }, [isContainer, bulk, bulkUom]); + + const dateError = + !isIntercity && !scheduledDate ? "Select a shipment date." : undefined; + const routeError = + multiRoute && !contractRouteId ? "Select a route." : undefined; const cargoValid = isContainer - ? unitErrors.every((line) => + ? lineErrors.every( + (e) => + !e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity, + ) && + unitErrors.every((line) => line.every((e) => !e.containerNumber && !e.vgmTons), ) - : bulkErrors.every((e) => !e.quantity && !e.hazardous && !e.reefer); + : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const canSubmit = - windowOpen && - Boolean(scheduledDate) && - (!needsRouteSelect || Boolean(contractRouteId)) && - (isContainer ? containerLines.some((l) => l.units.length > 0) : bulkLines.length > 0); + const formValid = cargoValid && !dateError && !routeError; /** The create-booking DTO from the current form state — shared by the * authoritative price preview and the actual submit so what GL confirms is - * exactly what gets booked. */ + * exactly what gets booked. Mirrors the portal form's buildDto. */ const buildPayload = (): Freight.CreateBookingUnderContractDto | null => { - if (!scheduledDate || !contract) return null; + if (!contract) return null; const payload: Freight.CreateBookingUnderContractDto = { - scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), + // Intercity bookings carry no date — staff assign a passing train later. + ...(scheduledDate + ? { scheduledDate: new Date(scheduledDate).toISOString() } + : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), // Equipment return is a container concern — bulk keeps the contract default. ...(isContainer @@ -569,14 +650,12 @@ export default function GlCreateBookingForm() { if (isContainer) { payload.containers = containerLines - .filter((l) => l.units.length > 0) + .filter((l) => Number(l.quantity) >= 1) .map((l) => ({ containerSize: l.containerSize, - quantity: l.units.length, - // Counts are derived from the per-unit toggles — they can never - // exceed the line quantity. - hazardousQuantity: l.units.filter((u) => u.hazardous).length, - reeferQuantity: l.units.filter((u) => u.reefer).length, + quantity: Number(l.quantity), + hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(l.reeferQuantity || 0) || undefined, units: l.units.map((u) => ({ containerNumber: u.containerNumber.trim().toUpperCase(), ...(u.sealNumber ? { sealNumber: u.sealNumber } : {}), @@ -584,28 +663,27 @@ export default function GlCreateBookingForm() { })), })); } else { - payload.bulkLines = bulkLines.map((l) => ({ - ...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}), - ...(l.cargoWeightTons !== "" - ? { cargoWeightTons: Number(l.cargoWeightTons) } - : {}), - ...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}), - ...(l.hazardousQuantity !== "" - ? { hazardousQuantity: Number(l.hazardousQuantity) } - : {}), - ...(l.reeferQuantity !== "" - ? { reeferQuantity: Number(l.reeferQuantity) } - : {}), - })); + payload.bulkLines = [ + { + cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null, + ...(bulk.cargoWeightTons !== "" + ? { cargoWeightTons: Number(bulk.cargoWeightTons) } + : {}), + ...(bulk.itemCount !== "" ? { itemCount: Number(bulk.itemCount) } : {}), + hazardousQuantity: Number(bulk.hazardousQuantity || 0) || undefined, + reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined, + }, + ]; } return payload; }; // Authoritative price preview (same pricing pass the booking persists at - // create): rail freight + first/last mile + overweight + every surcharge. - // Fired when the price modal opens; the modal falls back to the contract - // unit-rate estimate while it loads. + // create): rail freight + first/last mile + overweight + every surcharge, + // plus the hard-block checks (20ft pairing, max capacity, container numbers + // already booked on the same train). Fired when the price modal opens; the + // modal falls back to the contract unit-rate estimate while it loads. const validateShipmentMutation = useMutation({ mutationFn: (dto: Freight.CreateBookingUnderContractDto) => contractsService.validateShipment(id ?? "", dto), @@ -629,15 +707,54 @@ export default function GlCreateBookingForm() { }; }, [validation, priceTotal]); - const displayTotal = serverTotal ?? priceTotal; + const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const pairingErrors = validation?.pairingErrors ?? []; const capacityErrors = validation?.capacityErrors ?? []; + const containerClashErrors = validation?.containerClashErrors ?? []; + const spaceErrors = validation?.spaceErrors ?? []; const overweightLines = validation?.overweightLines ?? []; + // Fallback while the server preview loads: the contract's frozen unit rates + // with the overweight surcharge folded in — same fallback the portal shows. + const displayTotal = useMemo(() => { + if (serverTotal) return serverTotal; + if (!priceTotal) return null; + if (!(overweightSurchargeAmount > 0)) return priceTotal; + return { + ...priceTotal, + lines: [ + ...priceTotal.lines, + { + label: "Overweight surcharge", + unitPrice: overweightSurchargeAmount, + unit: "flat" as const, + quantity: 1, + amount: overweightSurchargeAmount, + }, + ], + total: priceTotal.total + overweightSurchargeAmount, + }; + }, [serverTotal, priceTotal, overweightSurchargeAmount]); + + const submitPending = + mutations.createBooking.isPending || mutations.completeBooking.isPending; + + // Block confirm until the authoritative server price is in hand — the client + // estimate is display-only; booking on it would confirm an un-validated, + // possibly wrong price. Same guard as the portal form. + const confirmDisabled = + submitPending || + validateShipmentMutation.isPending || + pairingErrors.length > 0 || + capacityErrors.length > 0 || + containerClashErrors.length > 0 || + spaceErrors.length > 0 || + !serverTotal; + const openPriceModal = () => { // Surface the per-field errors (portal-parity validation) instead of // sending an invalid payload to the price preview. - if (!cargoValid) { + if (!formValid) { setShowErrors(true); return; } @@ -651,11 +768,15 @@ export default function GlCreateBookingForm() { }; const handleSubmit = () => { - if (!contract || !windowOpen || !cargoValid) return; + if (!contract || !formValid) return; // Never book past unresolved 20ft pairing hard-blocks. if (pairingErrors.length > 0) return; // A line above the container type's max capacity can never book. if (capacityErrors.length > 0) return; + // A container already on another booking of the same train can never book. + if (containerClashErrors.length > 0) return; + // An export booking no open train can carry whole can never book. + if (spaceErrors.length > 0) return; const payload = buildPayload(); if (!payload) return; @@ -692,7 +813,7 @@ export default function GlCreateBookingForm() { }); }; - if (isLoading) { + if (isLoading || windowsLoading) { return (
@@ -713,32 +834,68 @@ export default function GlCreateBookingForm() { ); } - const bulkUom = bulkUnitOfMeasure(contract); + const header = ( + + + + {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} + + + {completeBookingId + ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` + : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} + + + + + ); + + // Coarse gate — same closed-state notice the customer sees on the portal. + // Intercity contracts are never window-gated. + if (!isIntercity && !windowOpen) { + return ( + + {header} + } + title="Booking is not open right now" + > + + {nextWindow?.windowOpensAt ? ( + <> + Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} + for{" "} + + {nextWindow.origin ?? "Origin"} →{" "} + {nextWindow.destination ?? "Destination"} + + . + + ) : ( + <>No upcoming booking window scheduled. + )} + + + Come back when the booking window opens to book this shipment. + + + + ); + } return ( - - - - {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} - - - {completeBookingId - ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` - : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} - - - - + {header} {bookingRequest ? ( ) : null} - {!windowsLoading && !windowOpen ? ( - } - title="Booking window is closed" - mb="lg" - > - GL can create a booking only while a window is open.{" "} - {nextWindow?.windowOpensAt ? ( - <> - Next window: {fmtWindowOpensAt(nextWindow.windowOpensAt)} EAT{" "} - for{" "} - - {nextWindow.origin ?? "Origin"} → {nextWindow.destination ?? "Destination"} - - . - - ) : ( - <>No upcoming booking window scheduled. - )} - - ) : null} - - {windowsLoading || windowOpen ? ( - <> } title="Route" description={ - needsRouteSelect + multiRoute ? "Choose which contracted route this shipment ships on." : "This shipment ships on the contract's only route." } /> - {needsRouteSelect ? ( + {multiRoute ? ( patchBulk(idx, { cargoTypeId: v ?? "" })} - data={bulkCargoOptions} - radius={10} - styles={fieldStyles} - /> - ) : null} - {bulkUom === "PER_TON" ? ( - patchBulk(idx, { cargoWeightTons: v })} - radius={10} - styles={fieldStyles} - /> - ) : ( - patchBulk(idx, { itemCount: v })} - radius={10} - styles={fieldStyles} - /> - )} - {contract.isHazardous ? ( - patchBulk(idx, { hazardousQuantity: v })} - radius={10} - styles={fieldStyles} - /> - ) : null} - {contract.isReefer ? ( - patchBulk(idx, { reeferQuantity: v })} - radius={10} - styles={fieldStyles} - /> - ) : null} - - ))} + + setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value })) + } + radius={10} + styles={fieldStyles} + /> + + setBulk((b) => ({ ...b, itemCount: e.currentTarget.value })) + } + radius={10} + styles={fieldStyles} + /> + {contract.isHazardous && ( + + setBulk((b) => ({ + ...b, + hazardousQuantity: e.currentTarget.value, + })) + } + radius={10} + styles={fieldStyles} + /> + )} + {contract.isReefer && ( + + setBulk((b) => ({ + ...b, + reeferQuantity: e.currentTarget.value, + })) + } + radius={10} + styles={fieldStyles} + /> + )} )} @@ -1204,37 +1351,57 @@ export default function GlCreateBookingForm() { ) : null} - - } - title="Schedule" - description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." - /> - {cargoQuery === null ? ( - } - > - Enter your cargo details first — available shipment days depend on - the wagons your cargo needs. + {isIntercity ? ( + + } + title="Schedule" + description="Intercity shipments have no fixed day." + /> + }> + The shipment rides the next import/export train passing through + the corridor. Operations assign it to a train with free capacity — + the customer is notified when it is accepted and payment is due. - ) : ( - - Shipment day * - - + + ) : ( + + } + title="Schedule" + description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for the cargo can be selected." + /> + {cargoQuery === null ? ( + } + > + Enter your cargo details first — available shipment days depend + on the wagons your cargo needs. + + ) : ( + + Shipment day * + + + + {showErrors && dateError && ( + + {dateError} + + )} - - )} - + )} + + )}