diff --git a/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts new file mode 100644 index 000000000..e635d19c1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seeds the admin-configurable "contract validity periods" setting (days). Stored + * as a dropdown_settings row whose options each hold a day count in `value`, so + * backoffice manages them through the existing Dropdown Settings UI and the + * contract staff-accept dialog only offers the configured durations. + */ +export class SeedContractValidityPeriods1792000000004 + implements MigrationInterface +{ + name = 'SeedContractValidityPeriods1792000000004'; + private readonly code = 'contract_validity_periods'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '180', label: '6 months' }, + { value: '365', label: '1 year' }, + { value: '730', label: '2 years' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'Contract Validity Periods (days)', + 'Validity durations (in days) a staff can choose when accepting a submitted contract.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts new file mode 100644 index 000000000..e88824e28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Customer shipment requests for GENERAL customs (Path B) contracts. The customer + * submits date + quantities; Global Logistics reviews, then creates the booking + * on their behalf and per-booking clearance begins. Additive — no change to + * existing tables; ONE_TIME contracts are unaffected. + */ +export class CreateBookingRequests1827000000000 implements MigrationInterface { + name = 'CreateBookingRequests1827000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'reference', type: 'varchar', length: '40', default: "''" }, + { name: 'contract_id', type: 'uuid' }, + { name: 'requested_by_user_id', type: 'uuid', isNullable: true }, + { name: 'contract_route_id', type: 'uuid', isNullable: true }, + { name: 'scheduled_date', type: 'timestamptz', isNullable: true }, + { name: 'status', type: 'varchar', length: '16', default: "'PENDING'" }, + { name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_booking_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'review_note', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['contract_id'], + referencedSchema: 'freight', + referencedTableName: 'contracts', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['created_booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }), + ); + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ + name: 'idx_booking_requests_contract_status', + columnNames: ['contract_id', 'status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_requests', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 92ab8769e..0e3133311 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service'; * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. - * - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM. */ describe('BookingTransitionService — operation review', () => { function makeService(serviceTypeCode: string) { @@ -78,18 +77,4 @@ describe('BookingTransitionService — operation review', () => { expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }), ); }); - - it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => { - const { service, bookingsRepository } = makeService('RAIL_CONTAINER'); - await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', { - amount: 1500, - }); - expect(bookingsRepository.update).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ - adjustedTotalAmount: 1500, - status: 'OPERATION_PRICE_PENDING_CONFIRM', - }), - ); - }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 70941d12b..b1d4192a5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -477,30 +477,6 @@ export class BookingTransitionService { return this.bookingsService.findById(updated!.id); } - /** - * Staff adjusts a booking's total price. Stores an override (with who/when/why) - * that supersedes the computed total for the customer, who sees an - * "Adjusted by EDR" badge. Passing null clears the adjustment. - */ - async adjustPrice( - bookingId: string, - amount: number | null, - staffId: string, - reason?: string, - ): Promise { - await this.bookingsService.findById(bookingId); - if (amount != null && amount < 0) { - throw new BadRequestException('Adjusted amount cannot be negative'); - } - await this.bookingsRepository.update(bookingId, { - adjustedTotalAmount: amount, - adjustedByStaffId: amount == null ? null : staffId, - adjustedAt: amount == null ? null : new Date(), - adjustmentReason: amount == null ? null : (reason ?? null), - } as never); - return this.bookingsService.findById(bookingId); - } - // ── Document clearance gate (post counter-sign) ─────────────────────────── /** @@ -861,17 +837,17 @@ export class BookingTransitionService { /** * Operations team reviews a pending operation request (capacity, documents, - * route). Three outcomes: + * route). Two outcomes: * - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED). * - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit. - * - ADJUST_PRICE → a new total is set; the customer must re-confirm it - * before the booking can enter the pool. + * + * The booking price is computed from the contract and is never adjusted here. */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE', + decision: 'ACCEPT' | 'REQUEST_CHANGES', actorId: string, - options: { note?: string; amount?: number } = {}, + options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); @@ -894,48 +870,10 @@ export class BookingTransitionService { return this.bookingsService.findById(bookingId); } - if (decision === 'ADJUST_PRICE') { - if (options.amount == null || options.amount < 0) { - throw new BadRequestException( - 'A non-negative adjusted amount is required to adjust the price', - ); - } - await this.bookingsRepository.update(bookingId, { - adjustedTotalAmount: options.amount, - adjustedByStaffId: actorId, - adjustedAt: new Date(), - adjustmentReason: options.note ?? null, - status: 'OPERATION_PRICE_PENDING_CONFIRM', - } as never); - return this.bookingsService.findById(bookingId); - } - // ACCEPT — enter the batch holding pool. return this.acceptOperationRequest(booking); } - /** - * Customer re-confirms (or rejects) an operations price adjustment. Accepting - * pushes the booking into the pool; rejecting returns it to the customer as an - * operation change request so they can resubmit or cancel. - */ - async confirmOperationPrice( - bookingId: string, - accept: boolean, - ): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']); - - if (!accept) { - await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', - } as never); - return this.bookingsService.findById(bookingId); - } - - return this.acceptOperationRequest(booking); - } - /** * Move a reviewed operation request forward after Marketing accepts. * diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 331706c5a..0f413199b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, - AdjustPriceDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, @@ -52,7 +51,6 @@ import { ReviewDocumentDto, RequestOperationDto, OperationReviewDto, - ConfirmOperationPriceDto, StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; @@ -404,24 +402,7 @@ export class BookingsController { id, dto.decision, resolveAuthUserId(user), - { note: dto.note, amount: dto.amount }, - ); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(':id/operation/confirm-price') - @ApiOperation({ - summary: - 'Customer confirms or rejects an operations price adjustment ' + - '(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)', - }) - async confirmOperationPrice( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: ConfirmOperationPriceDto, - ) { - const booking = await this.transitionService.confirmOperationPrice( - id, - dto.accept, + { note: dto.note }, ); return this.transitionService.enrichBookingResponse(booking); } @@ -521,25 +502,6 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/adjust-price') - @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ - summary: 'Staff adjust booking total price (override; null clears it)', - }) - async adjustPrice( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AdjustPriceDto, - @CurrentUser() user: AuthUserPayload, - ) { - const booking = await this.transitionService.adjustPrice( - id, - dto.amount ?? null, - resolveAuthUserId(user), - dto.reason, - ); - return this.transitionService.enrichBookingResponse(booking); - } - @Post(':id/government-expedite') @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 69a8232d7..2e2865d8b 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): { outputCode: string | null; includesCustoms: boolean; } { - const includesCustoms = booking.serviceType?.includesCustoms ?? false; + // Customs applies when EITHER the service type bundles it OR the booking was + // created with customsClearingEnabled (copied from the contract). Contract + // bookings carry customsClearingEnabled even when the serviceType relation + // isn't loaded / has includesCustoms=false — without this the per-booking + // clearance grid would resolve empty. + const includesCustoms = + Boolean(booking.serviceType?.includesCustoms) || + Boolean(booking.customsClearingEnabled); return { inputCode: clearanceSettingCode( booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 217353987..f27b375bb 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -1,10 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { - IsBoolean, IsDateString, IsIn, IsInt, - IsNumber, IsOptional, IsString, Max, @@ -70,22 +68,6 @@ export class RejectBookingDto { reason?: string; } -export class AdjustPriceDto { - @ApiPropertyOptional({ - description: - 'New total price. Omit or send null to clear a previous adjustment.', - }) - @IsOptional() - @IsNumber() - @Min(0) - amount?: number | null; - - @ApiPropertyOptional({ description: 'Reason for the adjustment' }) - @IsOptional() - @IsString() - reason?: string; -} - export class ReviewDocumentDto { @ApiProperty({ description: 'The document fileKey being reviewed' }) @IsString() @@ -117,12 +99,12 @@ export class OperationReviewDto { @ApiProperty({ description: 'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' + - 'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' + - 'customer must re-confirm before it proceeds.', - enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'], + 'returns it to the customer with a note. The booking price is computed ' + + 'from the contract and cannot be adjusted by staff.', + enum: ['ACCEPT', 'REQUEST_CHANGES'], }) - @IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE']) - decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE'; + @IsIn(['ACCEPT', 'REQUEST_CHANGES']) + decision!: 'ACCEPT' | 'REQUEST_CHANGES'; @ApiPropertyOptional({ description: 'Required for REQUEST_CHANGES (what the customer must fix).', @@ -130,22 +112,4 @@ export class OperationReviewDto { @IsOptional() @IsString() note?: string; - - @ApiPropertyOptional({ - description: 'New total price — required for ADJUST_PRICE.', - }) - @IsOptional() - @IsNumber() - @Min(0) - amount?: number; -} - -export class ConfirmOperationPriceDto { - @ApiProperty({ - description: - 'true to accept the operations price adjustment and proceed to the ' + - 'batch pool; false to reject it (returns to operation changes requested).', - }) - @IsBoolean() - accept!: boolean; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts new file mode 100644 index 000000000..9529cabbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -0,0 +1,45 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { BookingRequest } from './entities/booking-request.entity'; + +@Injectable() +export class BookingRequestRepository extends BaseRepository { + constructor( + @InjectRepository(BookingRequest) + repository: Repository, + ) { + super(repository); + } + + /** All requests on a contract, newest first. */ + async findForContract(contractId: string): Promise { + return this.repository.find({ + where: { contractId }, + order: { createdAt: 'DESC' }, + }); + } + + /** GL queue: pending requests across all contracts, oldest first. */ + async findPending(): Promise { + return this.repository.find({ + where: { status: 'PENDING' }, + order: { createdAt: 'ASC' }, + relations: { contract: true }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { contract: true }, + }); + } + + /** Total rows — used to mint the next sequential reference. */ + async count(): Promise { + return this.repository.count(); + } +} 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 new file mode 100644 index 000000000..199276862 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -0,0 +1,199 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Freight } from '@edr/types'; + +import { BookingRequestRepository } from './booking-request.repository'; +import { ContractsService } from './contracts.service'; +import { ContractBookingService } from './contract-booking.service'; +import { BookingRequest } from './entities/booking-request.entity'; +import { Contract } from './entities/contract.entity'; +import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; + +/** + * Customer shipment requests on GENERAL customs (Path B) contracts. The customer + * submits a request (date + quantities); GL reviews the queue and, on accept, + * creates the booking — after which per-booking clearance begins. ONE_TIME and + * Path A do not use this flow. + */ +@Injectable() +export class BookingRequestService { + constructor( + private readonly repo: BookingRequestRepository, + private readonly contractsService: ContractsService, + private readonly contractBookingService: ContractBookingService, + ) {} + + /** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */ + private assertGeneralCustoms(contract: Contract): void { + if ( + contract.contractKind !== 'GENERAL' || + !contract.customsClearingEnabled + ) { + throw new BadRequestException( + 'Shipment requests apply only to general customs-clearance contracts.', + ); + } + } + + /** Customer submits a shipment request. */ + async submit( + contractId: string, + dto: CreateBookingRequestDto, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + await this.contractsService.assertCustomerCanAccessContract(userId, contract); + this.assertGeneralCustoms(contract); + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new ConflictException( + 'The contract must be active before requesting a shipment.', + ); + } + + const isContainer = contract.freightType === 'CONTAINER'; + const hasLines = isContainer + ? (dto.containers?.length ?? 0) > 0 + : Boolean(dto.bulk); + if (!hasLines) { + throw new BadRequestException( + isContainer + ? 'Add at least one container line.' + : 'Enter the bulk cargo amount.', + ); + } + + // Validate requested container sizes against the contract cargo scope and + // remaining draw-down capacity (reuses the booking quantity-cap check). + if (isContainer) { + const allowed = new Set( + (contract.cargoScope ?? []) + .map((s) => s.containerSize) + .filter((s): s is string => !!s), + ); + for (const line of dto.containers ?? []) { + if (allowed.size && !allowed.has(line.containerSize)) { + throw new BadRequestException( + `Container size ${line.containerSize} is not in this contract's scope.`, + ); + } + } + } + await this.contractBookingService.assertRequestWithinCapacity(contract, { + containers: dto.containers, + bulk: dto.bulk, + }); + + const requestedLines: Freight.RequestedShipmentLines = isContainer + ? { + containers: (dto.containers ?? []).map((l) => ({ + containerSize: l.containerSize, + quantity: l.quantity, + hazardousQuantity: l.hazardousQuantity, + reeferQuantity: l.reeferQuantity, + })), + } + : { + bulk: { + cargoTypeId: dto.bulk?.cargoTypeId ?? null, + cargoWeightTons: dto.bulk?.cargoWeightTons, + itemCount: dto.bulk?.itemCount, + hazardousQuantity: dto.bulk?.hazardousQuantity, + }, + }; + + const reference = await this.generateReference(); + return this.repo.create({ + reference, + contractId, + requestedByUserId: userId ?? null, + contractRouteId: dto.contractRouteId ?? null, + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + status: 'PENDING', + requestedLines, + notes: dto.notes ?? null, + } as never); + } + + listForContract(contractId: string): Promise { + return this.repo.findForContract(contractId); + } + + async findOne(requestId: string): Promise { + const request = await this.repo.findById(requestId); + if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); + return request; + } + + queue(): Promise { + return this.repo.findPending(); + } + + private async findPending(requestId: string): Promise { + const request = await this.repo.findById(requestId); + if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException( + `This request is already ${request.status.toLowerCase()}.`, + ); + } + return request; + } + + /** + * GL accepts a request. The booking itself is created via the GL booking form + * (POST /contracts/:id/bookings) which carries the per-unit container data the + * request omits; this endpoint records the acceptance + links the created + * booking. `bookingId` is supplied by the GL form on success. + */ + async accept( + requestId: string, + bookingId: string, + staffId?: string, + ): Promise { + const request = await this.findPending(requestId); + await this.repo.update(requestId, { + status: 'ACCEPTED', + createdBookingId: bookingId, + reviewedByStaffId: staffId ?? null, + reviewedAt: new Date(), + } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + /** GL rejects a request with a note. */ + async reject( + requestId: string, + note?: string, + staffId?: string, + ): Promise { + const request = await this.findPending(requestId); + await this.repo.update(requestId, { + status: 'REJECTED', + reviewNote: note ?? null, + reviewedByStaffId: staffId ?? null, + reviewedAt: new Date(), + } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + /** Customer cancels their own pending request. */ + async cancel(requestId: string, userId?: string): Promise { + const request = await this.findPending(requestId); + if (request.requestedByUserId && request.requestedByUserId !== userId) { + throw new ForbiddenException('You can only cancel your own requests.'); + } + await this.repo.update(requestId, { status: 'CANCELLED' } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + private async generateReference(): Promise { + const count = await this.repo.count(); + const seq = String(count + 1).padStart(6, '0'); + return `SR-${seq}`; + } +} 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 e7b4c373c..808fd8a63 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 @@ -98,6 +98,12 @@ export class ContractBookingService { const reference = await this.generateReference(); const freightType = contract.freightType; + // GENERAL + customs (Path B) runs per-booking clearance: the booking starts + // in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to + // operations, and there is NO contract-level clearance cycle to link. + const generalCustoms = + contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -105,7 +111,7 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: 'OPERATION_REQUEST_PENDING', + status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -165,9 +171,11 @@ export class ContractBookingService { warnings.push(...computed.warnings); } - // Path B side effects: link the clearance cycle, seed post-booking - // milestones onto the booking, and advance the contract. - if (contract.customsClearingEnabled) { + // ONE_TIME customs (legacy contract-cycle path): link the contract clearance + // cycle to this booking, seed post-booking milestones, and lock the contract + // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle + // and must stay CONTRACT_ACTIVE so further shipment requests can be accepted. + if (contract.customsClearingEnabled && !generalCustoms) { const cycle = await this.contractsRepository.currentCycle(contract.id); if (cycle) { await this.contractsRepository.linkBooking(cycle.id, booking.id); @@ -180,6 +188,14 @@ export class ContractBookingService { status: 'ACTIVE_SHIPMENT_IN_PROGRESS', clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); + } else if (generalCustoms) { + // Per-booking clearance: seed post-booking milestones on the booking (no + // cycle needed) and leave the contract active. The booking now drives its + // own clearance via the booking-level pipeline. + await this.milestoneService.seedPostBookingMilestones( + booking.id, + contract.tradeDirection, + ); } const result = await this.bookingsRepository.findByIdWithFiles(booking.id); @@ -192,14 +208,24 @@ export class ContractBookingService { */ private async assertGate(contract: Contract, isGlActor: boolean): Promise { if (contract.customsClearingEnabled) { - // Path B — Global Logistics creates the booking ON BEHALF OF the customer - // once GL has finalized the pre-booking clearance. The customer never - // books a customs contract himself. + // Path B — Global Logistics creates the booking ON BEHALF OF the customer. + // The customer never books a customs contract himself. if (!isGlActor) { throw new ForbiddenException( 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', ); } + if (contract.contractKind === 'GENERAL') { + // GENERAL customs has NO contract clearance cycle — GL books per accepted + // shipment request while the contract is active; clearance is per booking. + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new BadRequestException( + 'Contract must be active to book a shipment.', + ); + } + return 'GL_ET'; + } + // ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { throw new BadRequestException( 'Contract clearance is not ready for booking yet.', @@ -233,6 +259,42 @@ export class ContractBookingService { * cap. Container caps are per size; bulk is a single tons/items cap. Bookings * that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold. */ + /** + * Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors + * {@link assertWithinQuantityCap} but reads the request's quantity shape. + */ + async assertRequestWithinCapacity( + contract: Contract, + lines: { + containers?: Array<{ containerSize: string; quantity: number }>; + bulk?: { cargoWeightTons?: number; itemCount?: number }; + }, + ): Promise { + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped contract + + if (contract.freightType === 'CONTAINER') { + for (const line of lines.containers ?? []) { + const cap = capacity.find((c) => c.containerSize === line.containerSize); + if (!cap || cap.remaining == null) continue; + if (line.quantity > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`, + ); + } + } + } else { + const requested = + (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; + const cap = capacity.find((c) => c.cap != null); + if (cap && cap.remaining != null && requested > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} remain on this contract.`, + ); + } + } + } + private async assertWithinQuantityCap( contract: Contract, dto: CreateBookingUnderContractDto, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 01d2cb2ef..4fcbc9a6c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -16,6 +16,7 @@ import { FileRecord } from '../files/entities/file.entity'; import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { ContractPricingService } from './contract-pricing.service'; @@ -27,6 +28,14 @@ import { Contract } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; import { SignContractDto } from './dto/sign-contract.dto'; +/** + * Dropdown-settings code holding the admin-configured contract validity options + * (each option's `value` is a day count). The staff accept dialog reads the same + * code, so accept can only use a configured duration. See the seed migration + * `SeedContractValidityPeriods`. + */ +const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; + /** Status-machine guard mirroring booking-status.util. */ function assertContractStatus(contract: Contract, allowed: string[]): void { if (!allowed.includes(contract.status)) { @@ -46,6 +55,7 @@ export class ContractTransitionService { private readonly pricingService: ContractPricingService, private readonly approvalRulesService: ApprovalRulesService, private readonly cargoTypesService: CargoTypesService, + private readonly dropdownSettingsService: DropdownSettingsService, private readonly filesService: FilesService, private readonly signaturesService: SignaturesService, private readonly milestoneService: ClearanceMilestoneService, @@ -101,6 +111,8 @@ export class ContractTransitionService { ); } + await this.assertValidityDaysConfigured(validityDays); + const validFrom = new Date(); const validUntil = new Date(validFrom); validUntil.setDate(validUntil.getDate() + validityDays); @@ -118,6 +130,37 @@ export class ContractTransitionService { return this.contractsService.findById(contractId); } + /** + * Ensure the chosen validity (days) is one of the admin-configured options in + * the `contract_validity_periods` dropdown setting. If the setting is missing + * or has no options yet, fall back to the DTO range check (already applied) so + * acceptance is never hard-blocked before an admin configures the list. + */ + private async assertValidityDaysConfigured(validityDays: number): Promise { + let setting; + try { + setting = await this.dropdownSettingsService.getByCode( + CONTRACT_VALIDITY_PERIODS_CODE, + ); + } catch { + // Not configured yet — keep the flow working with the DTO range only. + return; + } + + const allowed = (setting.children ?? []) + .map((o) => Number(o.value)) + .filter((n) => Number.isFinite(n)); + if (allowed.length === 0) return; + + if (!allowed.includes(validityDays)) { + throw new BadRequestException( + `Validity ${validityDays} days is not a configured option. Allowed: ${allowed + .sort((a, b) => a - b) + .join(', ')} days.`, + ); + } + } + /** * Build contract approval steps from the system approval_rules chain (US-06: * container → line staff + director; bulk → directors + CEO). Mirrors the @@ -520,7 +563,16 @@ export class ContractTransitionService { contract.customsClearingEnabled ?? false, ); - if (clearanceCode) { + // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract + // level: there is no contract clearance cycle. The contract just becomes + // active; the customer then files shipment requests and GL books + clears + // each one. ONE_TIME customs and Path A self-clearance keep the contract + // cycle below. + const isGeneralCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + + if (clearanceCode && !isGeneralCustoms) { // Open a clearance cycle, seed the pre-booking milestones, and route the // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the // distinction is enforced at the review/finalize endpoints, not here. @@ -531,7 +583,8 @@ export class ContractTransitionService { updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { - // No clearance gate (DOMESTIC) — ready for the customer to book directly. + // No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which + // clears per booking). Ready for shipment requests / direct booking. updates.status = contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; updates.clearanceStatus = 'NOT_APPLICABLE'; 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 7ec2611fa..0ef013347 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -43,6 +43,7 @@ import { ContractClearanceService } from './contract-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { BookingRequestService } from './booking-request.service'; import { SignaturesService } from '../signatures/signatures.service'; import { CreateContractDto } from './dto/create-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto'; @@ -58,6 +59,10 @@ import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingRequestDto, + ReviewBookingRequestDto, +} from './dto/create-booking-request.dto'; import { AdviseDutyDto, AssignRiskDto, @@ -78,9 +83,78 @@ export class ContractsController { private readonly contractBookingService: ContractBookingService, private readonly milestoneService: ClearanceMilestoneService, private readonly glOperationsService: GlOperationsService, + private readonly bookingRequestService: BookingRequestService, private readonly signaturesService: SignaturesService, ) {} + // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── + // STATIC routes declared before any `:id`-param route so Nest matches them + // (mirrors the clearance/queue ordering below). + + @Get('booking-requests/queue') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' }) + bookingRequestQueue() { + return this.bookingRequestService.queue(); + } + + @Get('booking-requests/:reqId') + @ApiOperation({ summary: 'A single shipment request' }) + getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) { + return this.bookingRequestService.findOne(reqId); + } + + @Post('booking-requests/:reqId/accept') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' }) + acceptBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @Body() body: { bookingId: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.accept( + reqId, + body.bookingId, + resolveAuthUserId(user), + ); + } + + @Post('booking-requests/:reqId/reject') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL rejects a shipment request' }) + rejectBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @Body() dto: ReviewBookingRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user)); + } + + @Post('booking-requests/:reqId/cancel') + @ApiOperation({ summary: 'Customer cancels their own pending shipment request' }) + cancelBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user)); + } + + @Post(':id/booking-requests') + @ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' }) + submitBookingRequest( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user)); + } + + @Get(':id/booking-requests') + @ApiOperation({ summary: 'List the shipment requests on a contract' }) + listBookingRequests(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingRequestService.listForContract(id); + } + @Post() @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 089081c3e..c595dbf5f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -8,6 +8,7 @@ import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BookingsModule } from '../bookings/bookings.module'; @@ -20,6 +21,8 @@ import { ContractClearanceService } from './contract-clearance.service'; import { ContractBookingService } from './contract-booking.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; +import { BookingRequestService } from './booking-request.service'; +import { BookingRequestRepository } from './booking-request.repository'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; @@ -32,6 +35,7 @@ import { ContractClearanceCycle } from './entities/contract-clearance-cycle.enti import { ContractDocumentReview } from './entities/contract-document-review.entity'; import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ClearanceIncident } from './entities/clearance-incident.entity'; +import { BookingRequest } from './entities/booking-request.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; @@ -54,11 +58,13 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractDocumentReview, ClearanceMilestone, ClearanceIncident, + BookingRequest, Booking, BookingContainerUnit, ]), RuleEngineModule, FileUploadSettingsModule, + DropdownSettingsModule, FilesModule, MinioModule, SignaturesModule, @@ -82,6 +88,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractBookingService, ClearanceMilestoneService, GlOperationsService, + BookingRequestService, + BookingRequestRepository, // Contract PDF providers (template resolution + render + PDF) — stateless // helpers reused from src/contracts/. ContractTemplateResolver, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts new file mode 100644 index 000000000..11d50494a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +/** A requested container line (no per-unit data — GL enters that at booking). */ +export class RequestContainerLineDto { + @ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' }) + @IsString() + containerSize!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; +} + +export class RequestBulkLineDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string | null; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoWeightTons?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + itemCount?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; +} + +/** Customer's shipment request on a GENERAL customs contract. */ +export class CreateBookingRequestDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' }) + @IsOptional() + @IsUUID() + contractRouteId?: string; + + @ApiPropertyOptional({ description: 'Preferred shipment day (informational).' }) + @IsOptional() + @IsString() + scheduledDate?: string; + + @ApiPropertyOptional({ type: [RequestContainerLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RequestContainerLineDto) + containers?: RequestContainerLineDto[]; + + @ApiPropertyOptional({ type: RequestBulkLineDto }) + @IsOptional() + @ValidateNested() + @Type(() => RequestBulkLineDto) + bulk?: RequestBulkLineDto; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +export class ReviewBookingRequestDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts new file mode 100644 index 000000000..6582376d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -0,0 +1,61 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import type { Freight } from '@edr/types'; + +import { Contract } from './contract.entity'; + +/** + * A customer's request to ship under a GENERAL customs (Path B) contract. The + * customer cannot book directly; they submit the date + quantities here, Global + * Logistics reviews the queue, then creates the booking on their behalf — after + * which per-booking customs clearance begins. ONE_TIME contracts do not use this + * (they keep contract-level clearance). See plan: per-booking clearance. + */ +@Entity({ schema: 'freight', name: 'booking_requests' }) +@Index(['contractId']) +@Index(['status']) +@Index(['contractId', 'status']) +export class BookingRequest extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 40, default: '' }) + reference!: string; + + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) + contractRouteId?: string | null; + + /** Customer's preferred shipment day — informational; GL sets the binding date. */ + @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) + scheduledDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'PENDING' }) + status!: Freight.BookingRequestStatus; + + /** Requested quantities (container lines or one bulk line) — no per-unit data. */ + @Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" }) + requestedLines!: Freight.RequestedShipmentLines; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; + + /** Set when GL accepts the request and creates the booking. */ + @Column({ name: 'created_booking_id', type: 'uuid', nullable: true }) + createdBookingId?: string | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'review_note', type: 'text', nullable: true }) + reviewNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts new file mode 100644 index 000000000..a3af6f572 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, +} from 'class-validator'; + +export interface CargoContainerLine { + containerSize: string; + quantity: number; +} + +/** + * Cargo-aware availability query. Beyond the route yards, it carries the cargo + * sizing so the service can check matching-wagon + train capacity per day. The + * `containers` array is passed as a JSON string in the query string (GET) and + * parsed here. + */ +export class AvailableDaysForCargoQueryDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + + @ApiProperty({ enum: ['CONTAINER', 'BULK'] }) + @IsEnum(['CONTAINER', 'BULK']) + freightType!: 'CONTAINER' | 'BULK'; + + @ApiPropertyOptional({ description: 'Bulk cargo type code (e.g. COFFEE).' }) + @IsOptional() + @IsString() + cargoTypeCode?: string; + + @ApiPropertyOptional({ description: 'Total bulk weight in tons.' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsNumber() + @Min(0) + totalWeightTons?: number; + + @ApiPropertyOptional({ + description: 'Container lines as a JSON string: [{containerSize,quantity}].', + }) + @IsOptional() + @Transform(({ value }) => { + if (value == null || value === '') return undefined; + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } + }) + @IsArray() + containers?: CargoContainerLine[]; +} 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 08fc74172..af6ece15a 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 @@ -33,6 +33,7 @@ import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-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"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; @@ -123,6 +124,24 @@ export class TrainSchedulingController { ); } + @Get("available-days-for-cargo") + // No staff guard: customers (portal) and GL (backoffice) both hit this while + // creating a booking to find which DAYS are feasible for THIS cargo — i.e. the + // route has a train with remaining capacity AND enough matching-type wagons. + @ApiOperation({ + summary: "Days bookable for a specific cargo (wagon + train capacity aware)", + }) + getAvailableDaysForCargo(@Query() query: AvailableDaysForCargoQueryDto) { + return this.trainSchedulingService.getAvailableDaysForCargo({ + originYardId: query.originYardId, + destinationYardId: query.destinationYardId, + freightType: query.freightType, + cargoTypeCode: query.cargoTypeCode, + totalWeightTons: query.totalWeightTons, + containers: query.containers, + }); + } + @Get("container/eligible-bookings") @TrainSchedulingView() @ApiOperation({ summary: "List eligible container bookings" }) 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 92aca8ac8..a74a4326e 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 @@ -2074,7 +2074,18 @@ export class TrainSchedulingService { * Supports sub-route matching: if originYardId and/or destinationYardId are provided, * returns schedules whose route passes through both yards in the correct order. */ - async getBookableSchedules(originYardId?: string, destinationYardId?: string) { + /** + * Raw OPEN same-route schedule entities a new booking may target (with the + * relations needed for capacity/fleet checks). Shared by getBookableSchedules + * (which maps to list items) and getAvailableDaysForCargo (which needs the raw + * originStationId / scheduledDepartureDate / trainSet). + */ + private async getBookableScheduleEntities( + originYardId?: string, + destinationYardId?: string, + ): Promise< + import('../train-schedules/entities/train-schedule.entity').TrainSchedule[] + > { const schedules = await this.trainSchedulesRepository.findAll({ where: { bookingWindowStatus: 'OPEN', @@ -2089,7 +2100,7 @@ export class TrainSchedulingService { order: { scheduledDepartureDate: 'ASC' }, }); - const filteredSchedules = schedules + return schedules .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status)) .filter((s) => { // Build the full stop list: origin -> milestones (ordered) -> destination @@ -2128,10 +2139,15 @@ export class TrainSchedulingService { } return true; - }) - .map((s) => this.mapScheduleListItem(s)); + }); + } - return filteredSchedules; + async getBookableSchedules(originYardId?: string, destinationYardId?: string) { + const schedules = await this.getBookableScheduleEntities( + originYardId, + destinationYardId, + ); + return schedules.map((s) => this.mapScheduleListItem(s)); } /** @@ -2151,6 +2167,93 @@ export class TrainSchedulingService { return { days: [...days].sort() }; } + /** + * Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given + * cargo. A day is selectable only when ≥1 OPEN schedule on the route that day + * has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that + * schedule's origin yard, and (b) remaining train capacity (not fully + * allocated). Days with trains but not enough matching wagons are excluded. + * Same `{ days: string[] }` shape as getAvailableDays — the customer still + * picks a DAY, not a train. + */ + async getAvailableDaysForCargo(input: { + originYardId?: string; + destinationYardId?: string; + freightType: 'CONTAINER' | 'BULK'; + cargoTypeCode?: string | null; + totalWeightTons?: number; + containers?: Array<{ containerSize: string; quantity: number }>; + }): Promise<{ days: string[] }> { + const schedules = await this.getBookableScheduleEntities( + input.originYardId, + input.destinationYardId, + ); + if (schedules.length === 0) return { days: [] }; + + const wagonTypes = await this.dataSource.getRepository(WagonType).find(); + + // Resolve the wagon type this cargo needs. + const requiredType = + input.freightType === 'BULK' + ? pickBulkWagonType(wagonTypes, input.cargoTypeCode) + : wagonTypes.find( + (wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive, + ); + if (!requiredType) return { days: [] }; + + // How many wagons of that type the cargo needs. + const slotsNeeded = this.wagonsNeededForCargo(input, requiredType); + + // AVAILABLE wagons of the required type, counted once per origin yard. + const availableByYard = new Map(); + const availableAt = async (yardId: string): Promise => { + const cached = availableByYard.get(yardId); + if (cached !== undefined) return cached; + const counts = await this.countFleetAvailability(yardId); + const n = + counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0; + availableByYard.set(yardId, n); + return n; + }; + + const days = new Set(); + for (const s of schedules) { + const hasCapacity = + Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; + if (!hasCapacity) continue; + const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded; + if (!enoughWagons) continue; + if (s.scheduledDepartureDate) + days.add(eatDay(new Date(s.scheduledDepartureDate))); + } + return { days: [...days].sort() }; + } + + /** + * Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight / + * capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per + * wagon. Mirrors wagon-plan.util without fabricating Booking entities. + */ + private wagonsNeededForCargo( + input: { + freightType: 'CONTAINER' | 'BULK'; + totalWeightTons?: number; + containers?: Array<{ containerSize: string; quantity: number }>; + }, + wagonType: WagonType, + ): number { + if (input.freightType === 'BULK') { + const capacity = Number(wagonType.capacityTons) || 1; + const weight = Number(input.totalWeightTons ?? 0); + return Math.max(1, Math.ceil(weight / capacity)); + } + const teu = (input.containers ?? []).reduce((sum, c) => { + const per = c.containerSize === '40ft' ? 2 : 1; + return sum + per * Math.max(0, Number(c.quantity ?? 0)); + }, 0); + return Math.max(1, Math.ceil(teu / 2)); + } + /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */ async existsOpenScheduleOnRouteDay( originYardId: string, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index c0dd56f74..6c5dd9b07 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,27 +1,16 @@ -import { useState } from "react"; -import { Banknote, Pencil, Receipt } from "lucide-react"; -import { - Button, - Divider, - Group, - NumberInput, - Paper, - Stack, - Text, - Textarea, -} from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import toast from "react-hot-toast"; +import { Banknote, Receipt } from "lucide-react"; +import { Divider, Group, Paper, Stack, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; -import { bookingsService } from "@/services/bookings.service"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { - const qc = useQueryClient(); const computed = Number(booking.totalAmount); + // The booking price is computed from the contract and is NOT staff-editable. + // A historical `adjustedTotalAmount` (from before adjustments were removed) + // is still shown read-only so old records render correctly. const isAdjusted = booking.adjustedTotalAmount !== null && booking.adjustedTotalAmount !== undefined; @@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { const lineItems = booking.pricingBreakdown?.lineItems ?? []; - const [editing, setEditing] = useState(false); - const [amount, setAmount] = useState(effective); - const [reason, setReason] = useState(""); - - const adjustMutation = useMutation({ - mutationFn: (payload: { amount: number | null; reason?: string }) => - bookingsService.adjustPrice(booking.id, payload.amount, payload.reason), - onSuccess: () => { - toast.success("Price updated"); - setEditing(false); - qc.invalidateQueries({ queryKey: ["bookings"] }); - }, - onError: () => toast.error("Could not update price"), - }); - const fmt = (n: number) => `${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`; @@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { - -
- - {isAdjusted ? "Adjusted total" : "Total amount"} - - - {fmt(effective)} - - {isAdjusted && ( - - Computed: {fmt(computed)} - {booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""} - - )} -
- {!editing && ( - - )} -
- - {editing && ( - - setAmount(v === "" ? "" : Number(v))} - min={0} - radius="md" - prefix={`${booking.paymentCurrency} `} - thousandSeparator="," - /> -