diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 636bb7e05..66143834d 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/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index a3009ec3c..39bb7ef78 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') 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..56100b4e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1211,6 +1211,7 @@ export class BookingsService { destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -1262,6 +1263,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 +1288,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, }); 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..2c49ae650 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 @@ -125,6 +125,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-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..be6316ade 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository { options: ContractListFilterOptions & { page: number; pageSize: number; + search?: string; sortBy?: string; sortOrder?: 'ASC' | 'DESC'; }, @@ -128,6 +129,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' 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/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-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..719bce1ac 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 @@ -542,12 +560,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 +597,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 +620,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', + ); } /** @@ -700,8 +814,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 +880,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 +917,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. */ 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/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..848ce15f8 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 @@ -17,8 +17,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 +63,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'; @@ -2641,8 +2658,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 +2703,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) { @@ -3869,6 +3924,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 { 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/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} + + )} - - )} - + )} + + )}