diff --git a/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts new file mode 100644 index 000000000..052d46340 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * EDR last-mile is multi-truck: a booking can be served by as many trucks as it + * has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery + * were stamped once per `last_mile` record, so every truck shared one timestamp. + * These per-vehicle columns give each EDR truck its own arrival, leaving and + * weighed load — the same granularity self-haul trucks already have. + * + * Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit + * weighing UI). Named `*_tons` deliberately: the older + * customer_truck_assignments.gross_weight_kg is named kg but stores tonnes. + * All nullable — legacy rows predate per-truck tracking. + */ +export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface { + name = 'AddLastMileTruckArrivalDeparture2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL, + ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL + `); + + // A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one + // container the legacy scalar `container_number` can hold. Mirrors the + // self-haul customer_truck_containers child table. The scalar stays in place + // (synced to the first container) for backward compatibility. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE, + last_mile_id uuid NOT NULL, + container_number varchar(32) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment" + ON freight.last_mile_vehicle_containers (assignment_id) + `); + // A container rides exactly one truck per delivery. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container" + ON freight.last_mile_vehicle_containers (last_mile_id, container_number) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`); + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS arrived_at, + DROP COLUMN IF EXISTS departed_at, + DROP COLUMN IF EXISTS gross_weight_tons, + DROP COLUMN IF EXISTS net_weight_tons + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 037957367..e7682879b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction", expect(result).toBeNull(); expect(transaction).not.toHaveBeenCalled(); }); + + it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => { + const { service, defaultManager } = build({ + ...openInvoice, + status: Freight.InvoiceStatus.Draft, + }); + + await service.expirePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "prepaid", + ); + + const { where } = defaultManager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); +}); + +describe("BillingService.issuePayable", () => { + const dueAt = new Date("2026-01-02T00:00:00.000Z"); + + const build = (found: Record | null) => { + const manager = { + findOne: jest.fn().mockResolvedValue(found), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new BillingService( + { manager, transaction: jest.fn() } as never, + {} as never, + {} as never, + makeEvents() as never, + {} as never, + {} as never, + {} as never, + ); + return { service, manager }; + }; + + const issue = (service: BillingService) => + service.issuePayable( + Freight.InvoiceSource.Booking, + "booking-1", + dueAt, + "PREPAID", + ); + + it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => { + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Draft, + issuedAt: null, + }); + + const result = await issue(service); + + const patch = manager.update.mock.calls[0][2]; + expect(patch.status).toBe(Freight.InvoiceStatus.Pending); + expect(patch.dueAt).toBe(dueAt); + expect(patch.issuedAt).toBeInstanceOf(Date); + expect(result?.status).toBe(Freight.InvoiceStatus.Pending); + }); + + it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => { + const { service, manager } = build(null); + + await issue(service); + + const { where } = manager.findOne.mock.calls[0][1]; + expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); + }); + + it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => { + const issuedAt = new Date("2026-01-01T00:00:00.000Z"); + const { service, manager } = build({ + id: "inv-1", + invoiceNumber: "INV-20260101-00001", + status: Freight.InvoiceStatus.Pending, + issuedAt, + }); + + const result = await issue(service); + + expect(manager.update.mock.calls[0][2]).toEqual({ dueAt }); + expect(result?.issuedAt).toBe(issuedAt); + }); + + it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => { + const { service, manager } = build(null); + + await expect(issue(service)).resolves.toBeNull(); + expect(manager.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 3b14d6f4c..0f60876d4 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -826,8 +826,15 @@ export class BillingService { * Expire a source's currently-open invoice (its pay window closed before * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice * and transitions it to EXPIRED — a terminal, non-payable status (kept out of - * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice - * (already paid/cancelled/expired). + * `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to + * retire (already paid/cancelled/expired). + * + * DRAFT invoices are matched too, even though they were never issued: this is + * also the "retire the invoice this source no longer needs" path (a cancelled + * booking, or a full-amount invoice superseded by a partial-offer one). Skipping + * drafts would leave the stale one behind for `findPayable` to hand back — the + * superseding invoice would then never be minted, and a cancelled booking would + * keep a draft that a later `issuePayable` could still make payable. * * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in * the batch engine) to enlist in its DB transaction. @@ -850,7 +857,7 @@ export class BillingService { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, @@ -867,30 +874,58 @@ export class BillingService { } /** - * Sync a source's open invoice `dueAt` to its real pay-window deadline. The - * booking invoice is generated before the pay window opens (at booking - * creation/approval), so its printed due date is refreshed when the batch engine - * sets `paymentDeadline`. No-op when the source has no open invoice. + * Issue a source's invoice and stamp its real pay-window deadline — the single + * transition that makes a source payable. + * + * A source's invoice is minted DRAFT, before any pay window exists (e.g. a + * booking invoice is generated at creation / operation-accept, long before the + * batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`, + * so such an invoice is not settleable and the portal renders no pay button. + * The domain calls this at the moment the pay window actually opens (booking → + * `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues + * the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`. + * + * Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so + * a re-reserve never re-issues. No-op (returns null) when the source has no + * draft-or-open invoice (already paid/cancelled/expired). */ - async syncPayableDueDate( + async issuePayable( source: Freight.InvoiceSource, sourceId: string, dueAt: Date, type?: string, manager?: EntityManager, - ): Promise { + ): Promise { const mg = manager ?? this.dataSource.manager; const invoice = await mg.findOne(Invoice, { where: { source, sourceId, - status: In(OPEN_STATUSES), + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), ...(type ? { type } : {}), }, order: { issuedAt: "DESC" }, }); - if (!invoice) return; - await mg.update(Invoice, { id: invoice.id }, { dueAt }); + if (!invoice) return null; + + const issuing = invoice.status === Freight.InvoiceStatus.Draft; + const patch = { + dueAt, + ...(issuing + ? { + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } + : {}), + }; + await mg.update(Invoice, { id: invoice.id }, patch); + + if (issuing) { + this.logger.log( + `Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`, + ); + } + return { ...invoice, ...patch } as Invoice; } /** @@ -997,20 +1032,20 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); - // // DEMO: manually fire the gateway `payment.succeeded` callback here, without - // // waiting for real gateway settlement. Runs AFTER the paymentId link above so - // // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: - // // remove — real settlement flips this via the `${source}.invoice.paid` handler. - // if (!result.immediateSuccess) { - // await this.payment.handlePaymentEvent({ - // eventType: "payment.succeeded", - // eventId: `demo-${result.intentId}`, - // referenceId: invoice.sourceId, - // intentId: result.intentId, - // providerTxnId: result.providerTxnId, - // paidAt: (result.paidAt ?? new Date()).toISOString(), - // }); - // } + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } if (result.immediateSuccess) { await this.settleByPaymentId( 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 69d505c3b..2aed86027 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 @@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; import { ContractDocPhase } from '@edr/types'; - -import { Freight } from "@edr/types"; import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() @@ -1112,14 +1110,25 @@ export class BookingTransitionService { await this.bookingBatchService.pickExportSchedule(booking); } + // Mint the booking's invoice (DRAFT) so the priced order carries its billing + // record from accept onward. It is deliberately NOT issued here: accepting an + // operation only puts the booking in the batch holding pool — no slot has been + // offered and no pay window exists yet. Issuing at this point made the invoice + // payable straight away (portal invoice list/detail gate on invoice status + // alone), letting a customer pay before being selected for a batch, while the + // booking page correctly still showed it as not payable. The batch engine + // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window + // and the real deadline are created — matching the portal's `canPay` gate. const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( - `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, - ); - await this.invoiceService.updateStatus( - invoice.id, - Freight.InvoiceStatus.Pending, + `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`, ); + // TODO: road (truck) orders are an incomplete feature — they stop at the + // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no + // per-km pricing wired via roadKmPrice, no pay surface in the portal). They + // skip the train batch, so they never reach `reserve` and their invoice stays + // DRAFT / unpayable. When the road flow is built, issue its invoice + // (billing.issuePayable) at whatever transition opens the road pay window. if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { status: "ROAD_DISPATCH_PENDING", 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 3c9c7db14..533b51b3b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository { private async attachClearancePhases(contracts: Contract[]): Promise { if (contracts.length === 0) return; const ids = contracts.map((c) => c.id); - const rows: Array<{ contract_id: string; current_phase: string | null }> = - await this.dataSource.query( - `SELECT DISTINCT ON (contract_id) contract_id, current_phase - FROM freight.contract_clearance_cycles - WHERE contract_id = ANY($1) - ORDER BY contract_id, cycle_number DESC`, - [ids], - ); - const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase])); + const rows: Array<{ + contract_id: string; + current_phase: string | null; + booking_id: string | null; + booking_status: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ON (ccc.contract_id) + ccc.contract_id, ccc.current_phase, + b.id AS booking_id, b.status AS booking_status + FROM freight.contract_clearance_cycles ccc + LEFT JOIN freight.bookings b ON b.id = ccc.booking_id + WHERE ccc.contract_id = ANY($1) + ORDER BY ccc.contract_id, ccc.cycle_number DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r])); for (const contract of contracts) { - contract.clearancePhase = byContract.get(contract.id) ?? null; + const row = byContract.get(contract.id); + contract.clearancePhase = row?.current_phase ?? null; + contract.latestCycleBookingId = row?.booking_id ?? null; + contract.latestCycleBookingStatus = row?.booking_status ?? null; } } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index a526d632f..b5e0b8fb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -312,6 +312,14 @@ export class Contract extends BaseEntity { */ clearancePhase?: string | null; + /** + * Latest clearance cycle's linked booking (id + status), attached alongside + * clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart + * from a live one so it can offer a rebook. Not columns. + */ + latestCycleBookingId?: string | null; + latestCycleBookingStatus?: string | null; + /** * Body of the most recent CHANGES_REQUESTED review note, attached by * ContractsService.findById so the portal can show the customer what staff diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts index e07eec0b4..2659f7076 100644 --- a/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-vehicles.dto.ts @@ -1,16 +1,36 @@ -import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayUnique, + IsArray, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; export class LastMileVehicleInput { @IsUUID() vehicleId!: string; + /** + * Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk + * (the truck hauls loose tonnage and is weighed out on exit). + */ + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @IsString({ each: true }) + containerNumbers?: string[]; + + /** @deprecated Single-container form — use `containerNumbers`. Still accepted. */ @IsOptional() @IsString() containerNumber?: string; } -/** Replace the full set of vehicles (with their container numbers) on a delivery. */ +/** Replace the full set of vehicles (with their containers) on a delivery. */ export class SetVehiclesDto { @IsArray() @ValidateNested({ each: true }) diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index eee414275..e57c16b8a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -1,8 +1,9 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm'; import { Vehicle } from '../../vehicles/entities/vehicle.entity'; import { LastMile } from './last-mile.entity'; +import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity'; /** * One row per vehicle assigned to a last-mile delivery. A delivery can be @@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity { @JoinColumn({ name: 'vehicle_id' }) vehicle?: Vehicle; - /** Container this truck carries — auto-filled from the booking's container - * number when known, else entered manually at assignment time. */ + /** Legacy single container this truck carries. Kept in sync with the FIRST + * entry of `containers` for backward compatibility — a truck can hold 1x40ft + * or 2x20ft, so `containers` is the authoritative list. */ @Column({ name: 'container_number', type: 'varchar', nullable: true }) containerNumber?: string | null; + /** Containers riding this truck (1x40ft, or up to 2x20ft). */ + @OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true }) + containers?: LastMileVehicleContainer[]; + /** Actual distance driven by this truck (km), entered per vehicle. */ @Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true }) distanceKm?: number | null; + + /** This truck reached the warehouse (stamped by the arrival weighing step). */ + @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) + arrivedAt?: Date | null; + + /** This truck left the warehouse (stamped by the exit weighing step). */ + @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) + departedAt?: Date | null; + + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ + @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + grossWeightTons?: number | null; + + /** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the + * bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */ + @Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) + netWeightTons?: number | null; } diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts new file mode 100644 index 000000000..ffadb5a21 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity'; + +/** + * A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR + * 2x20ft, so the assignment needs more than the single legacy `container_number` + * scalar. Mirrors the self-haul `customer_truck_containers` child table. + */ +@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' }) +@Index(['assignmentId']) +export class LastMileVehicleContainer extends BaseEntity { + @Column({ name: 'assignment_id', type: 'uuid' }) + assignmentId!: string; + + @ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, { + nullable: false, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: LastMileVehicleAssignment; + + /** Denormalised for the "one container, one truck per delivery" unique index. */ + @Column({ name: 'last_mile_id', type: 'uuid' }) + lastMileId!: string; + + @Column({ name: 'container_number', type: 'varchar', length: 32 }) + containerNumber!: string; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index fa7ee59ec..29e857e2f 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -73,6 +73,12 @@ export class LastMileController { return this.lastMileService.arrivalTrucksForBooking(bookingId); } + @Get('booking/:bookingId/remaining-tons') + @ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' }) + remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.lastMileService.remainingTonsForBooking(bookingId); + } + @Post('accept/:reference') @BookingStaff(FREIGHT_PERMS.lastMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts index 289c2156f..f4f33dff8 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.module.ts @@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module'; import { LastMile } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; +import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity'; import { LastMileController } from './last-mile.controller'; import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileRepository } from './last-mile.repository'; @@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service'; @Module({ imports: [ - TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]), + TypeOrmModule.forFeature([ + LastMile, + LastMileContainerAllocation, + LastMileVehicleAssignment, + LastMileVehicleContainer, + ]), BillingModule, forwardRef(() => BookingsModule), VehiclesModule, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 1ee31c63a..1ee9d7191 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity'; +import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity'; import { LastMileRepository } from './last-mile.repository'; import { FilesService } from '../files/files.service'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -175,7 +182,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, order: { [sortBy]: sortOrder }, skip: (page - 1) * pageSize, @@ -200,7 +207,7 @@ export class LastMileService { relations: { booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } }, vehicle: true, - vehicleAssignments: { vehicle: true }, + vehicleAssignments: { vehicle: true, containers: true }, }, }); @@ -277,7 +284,7 @@ export class LastMileService { > { const [lm] = await this.lastMileRepository.findAll({ where: { bookingId }, - relations: { vehicle: true, vehicleAssignments: { vehicle: true } }, + relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } }, take: 1, }); if (!lm) return []; @@ -552,22 +559,164 @@ export class LastMileService { * for each added/removed vehicle. The first vehicle is mirrored onto the legacy * `vehicleId` column for back-compat with single-vehicle readers. */ + /** Container numbers on the booking (upper-cased). */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT bcu.container_number AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, + [bookingId], + ); + return rows.map((r) => r.containerNumber.trim().toUpperCase()); + } + + /** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */ + private async containerSizes(bookingId: string, numbers: string[]): Promise { + if (!numbers.length) return []; + const rows: Array<{ size: string | null }> = await this.dataSource.query( + `SELECT bc.container_size AS "size" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2) + AND bcu.deleted_at IS NULL`, + [bookingId, numbers], + ); + return rows.map((r) => (r.size ?? '').trim()); + } + + /** + * Bulk drawdown: how much of the booking's tonnage is still to be hauled — + * the booking VGM total minus the net weighed off every EDR truck that has + * already left. Both sides are tonnes, so no conversion. + */ + async remainingTonsForBooking(bookingId: string): Promise<{ + totalTons: number; + hauledTons: number; + remainingTons: number; + complete: boolean; + }> { + const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> = + await this.dataSource.query( + `SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons", + COALESCE(( + SELECT SUM(va.net_weight_tons) + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm + ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + WHERE lm.booking_id = b.id + AND va.deleted_at IS NULL + AND va.departed_at IS NOT NULL + ), 0) AS "hauledTons" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); + const totalTons = Number(row?.totalTons ?? 0); + const hauledTons = Number(row?.hauledTons ?? 0); + const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000); + return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 }; + } + + /** + * Truck capacity rules for a last-mile delivery. + * - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container + * must belong to the booking and ride exactly one truck; never more trucks + * than containers. + * - BULK: no containers — trucks haul loose tonnage, so the only limit is + * that there is tonnage left to haul. + */ + private async assertVehicleLoads( + bookingId: string, + desired: string[], + loads: Map, + ): Promise { + if (!desired.length) return; + + const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query( + `SELECT freight_type AS "freightType" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if ((booking?.freightType ?? '').toUpperCase() === 'BULK') { + const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId); + if (totalTons > 0 && remainingTons <= 0) { + throw new BadRequestException( + 'This bulk booking is fully hauled — no tonnage left to assign trucks for', + ); + } + return; + } + + const bookingNumbers = await this.bookingContainerNumbers(bookingId); + if (!bookingNumbers.length) return; // nothing to validate against + + const seen = new Set(); + for (const vehicleId of desired) { + const load = loads.get(vehicleId) ?? []; + if (load.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } + for (const n of load) { + if (!bookingNumbers.includes(n)) { + throw new BadRequestException(`Container ${n} is not one of this booking's containers`); + } + if (seen.has(n)) { + throw new ConflictException(`Container ${n} is already assigned to another truck`); + } + seen.add(n); + } + // A 40ft container fills the truck; only two 20ft share one. + if (load.length > 1) { + const sizes = await this.containerSizes(bookingId, load); + if (sizes.some((s) => s.includes('40'))) { + throw new BadRequestException( + 'A 40ft container fills the truck — assign only 1 container to this truck', + ); + } + } + } + + if (desired.length > bookingNumbers.length) { + throw new BadRequestException( + `Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`, + ); + } + } + async setVehicles( id: string, - inputs: Array<{ vehicleId: string; containerNumber?: string | null }>, + inputs: Array<{ + vehicleId: string; + containerNumbers?: string[] | null; + containerNumber?: string | null; + }>, ): Promise { const existing = await this.findById(id); - // Dedupe by vehicleId, keeping the container number; preserve order. - const desiredMap = new Map(); + // Dedupe by vehicleId, keeping the container load; preserve order. Accepts + // the legacy single `containerNumber` as a one-element load. + const desiredMap = new Map(); for (const inp of inputs) { - if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null); + if (!inp.vehicleId) continue; + const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : [])) + .map((n) => String(n).trim().toUpperCase()) + .filter(Boolean); + desiredMap.set(inp.vehicleId, load); } const desired = [...desiredMap.keys()]; const desiredSet = new Set(desired); + // Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk + // hauls tonnage until the booking is drawn down). + await this.assertVehicleLoads(existing.bookingId, desired, desiredMap); + const manager = this.dataSource.manager; const current = await manager.find(LastMileVehicleAssignment, { where: { lastMileId: id }, + relations: { containers: true }, }); const junctionSet = new Set(current.map((a) => a.vehicleId)); // Fold the legacy vehicleId into the release set — a vehicle assigned via the @@ -588,33 +737,58 @@ export class LastMileService { ); } } - // Vehicles that stay but whose container number changed. + // Vehicles that stay but whose container load changed (order-insensitive). + const loadKey = (list: string[]) => [...list].sort().join('|'); + const currentLoad = (a: LastMileVehicleAssignment) => + (a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase()); const changed = current.filter( (a) => desiredMap.has(a.vehicleId) && - (a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null), + loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)), ); await this.dataSource.transaction(async (tx) => { if (removed.length) { + // Child containers cascade on delete. await tx.delete(LastMileVehicleAssignment, { lastMileId: id, vehicleId: In(removed), }); } for (const vehicleId of added) { - await tx.insert(LastMileVehicleAssignment, { + const load = desiredMap.get(vehicleId) ?? []; + const inserted = await tx.insert(LastMileVehicleAssignment, { lastMileId: id, vehicleId, - containerNumber: desiredMap.get(vehicleId) ?? null, + // Legacy scalar stays in sync with the first container. + containerNumber: load[0] ?? null, }); + const assignmentId = inserted.identifiers[0]?.id as string | undefined; + if (assignmentId && load.length) { + await tx.insert( + LastMileVehicleContainer, + load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })), + ); + } } for (const row of changed) { + const load = desiredMap.get(row.vehicleId) ?? []; await tx.update( LastMileVehicleAssignment, { lastMileId: id, vehicleId: row.vehicleId }, - { containerNumber: desiredMap.get(row.vehicleId) ?? null }, + { containerNumber: load[0] ?? null }, ); + await tx.delete(LastMileVehicleContainer, { assignmentId: row.id }); + if (load.length) { + await tx.insert( + LastMileVehicleContainer, + load.map((containerNumber) => ({ + assignmentId: row.id, + lastMileId: id, + containerNumber, + })), + ); + } } }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c37430121..8c3c92193 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => { { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, { - syncPayableDueDate: jest.fn().mockResolvedValue(undefined), + issuePayable: jest.fn().mockResolvedValue(null), expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, @@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, @@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => { notifier as never, { addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never, trainSchedulingService as never, - { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, + { issuePayable: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, 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 18e1c0fc7..ef39ab138 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 @@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit { paymentDeadline: deadline, } as never); booking.trainScheduleId = scheduleId; - // The invoice was generated at booking creation/approval, before this pay - // window opened — refresh its printed due date to the real deadline. - await this.billing.syncPayableDueDate( + // The invoice was generated DRAFT at booking creation / operation-accept, + // before this pay window existed. Reserving is the moment the booking becomes + // payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and + // print the deadline as its due date — never earlier, or the customer could + // settle an invoice for a slot they have not been offered yet. Idempotent: a + // re-reserve only refreshes `dueAt`. + await this.billing.issuePayable( Freight.InvoiceSource.Booking, booking.id, deadline, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts new file mode 100644 index 000000000..d6b8ba10e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/maintenance-reschedule.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** + * Admin maintenance reschedule: move a train's departure to a new date/time. + * Every allocated booking rides along (links and wagon assignments untouched); + * only the dates move — the schedule's train set, route, and window rule + * snapshot all stay exactly as they were. + */ +export class MaintenanceRescheduleDto { + @ApiProperty({ + example: '2026-07-20T05:00:00.000Z', + description: 'New scheduled departure date/time (ISO 8601)', + }) + @IsISO8601() + newDepartureDate!: string; + + @ApiPropertyOptional({ description: 'Why the train is being moved (logged)' }) + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional({ + description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only', + }) + @IsOptional() + @IsString() + trigger?: string; + + @ApiPropertyOptional({ + description: + "The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings", + type: [String], + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + incomingBookingIds?: 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 e4efaab9a..bbb19dbee 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 @@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; +import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingJourneyService } from "./booking-journey.service"; @@ -711,6 +712,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/maintenance") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", + }) + async maintenanceReschedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MaintenanceRescheduleDto, + ) { + await this.trainSchedulingService.maintenanceReschedule(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ 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 f417f3dc2..b92a7ea90 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 @@ -91,6 +91,7 @@ import { import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; +import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; @@ -864,6 +865,121 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Maintenance reschedule: the admin moves a train (with everything aboard) to + * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window + * phase and inside the booking lead window — a maintenance move is an + * operational fact, not a planning choice. What moves and what stays: + * + * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every + * aboard/targeted booking's scheduledDate (the day-pool queries key on it, + * so a booking left on the old day would fall out of its own train's pool). + * - STAYS: train set, wagon assignments, schedule↔booking links, route, + * maxWagons, and the window RULE snapshot. Stamped window times are only + * re-derived for PRE_WINDOW schedules (their window hasn't run yet); a + * schedule mid- or post-window keeps its timeline untouched. + * + * Customers of every moved booking are notified (maintenanceMoved). + */ + async maintenanceReschedule( + id: string, + dto: MaintenanceRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot reschedule a ${schedule.status.toLowerCase()} train`, + ); + } + + const departure = new Date(dto.newDepartureDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + if (departure.getTime() <= Date.now()) { + throw new BadRequestException('New departure must be in the future.'); + } + + const deltaMs = + departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); + const scheduledArrivalDate = schedule.scheduledArrivalDate + ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) + : undefined; + + // PRE_WINDOW only: the stamped open/close were derived from the old + // departure and the window hasn't opened yet, so re-derive them from the + // schedule's own rule snapshot against the new date (joining the target + // day's route group timeline when one exists, exactly like + // updateScheduleDate). Mid/post-window schedules keep their timeline. + const windowFields = + schedule.windowPhase === 'PRE_WINDOW' + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(departure, merged) + : computeImportWindowTimes(departure, merged, new Date()); + const anchor = + schedule.direction === 'EXPORT' + ? null + : await this.findGroupWindowAnchor( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + departure, + ); + return anchor + ? this.groupWindowFieldsFrom(anchor, departure) + : { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + }; + })() + : {}; + + await this.dataSource.getRepository(TrainSchedule).update(id, { + scheduledDepartureDate: departure, + ...(scheduledArrivalDate ? { scheduledArrivalDate } : {}), + ...windowFields, + }); + + // Everything aboard or targeted rides along: bookings linked on the train + // (schedule_bookings) plus reservations still pointing at it via + // train_schedule_id (paid-but-unlinked, awaiting payment, …). + const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId); + const targeted = await this.dataSource.getRepository(Booking).find({ + where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])], + relations: { company: true }, + }); + const aboard = targeted.filter( + (b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status), + ); + if (aboard.length) { + await this.dataSource + .getRepository(Booking) + .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); + for (const booking of aboard) { + this.bookingNotifier.maintenanceMoved(booking, departure); + } + } + + this.logger.log( + `[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` + + `(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` + + `${aboard.length} booking(s) moved with the train.`, + ); + void this.emitWindowState(id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure @@ -4717,6 +4833,7 @@ export class TrainSchedulingService { createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, + direction: schedule.direction ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: @@ -5942,6 +6059,64 @@ export class TrainSchedulingService { (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]), ); + // The trainSet slots below are the PLANNED wagons (one per allocation). A + // schedule tied to a built train hauls EVERY coupled wagon — empty ones + // included (the pull-limit check already counts their tare) — so append the + // train's remaining wagons as consist-only entries and the composition views + // (scheduling-v2 finalize, batch-board composition tab) draw the train as it + // really is: loaded slots first, then the empty consist. Skipped for frozen + // (dispatched/arrived) schedules: their wagons are released and re-pinned to + // later trains, so the live consist no longer describes THIS departure. + const coveredPhysicalIds = new Set(); + for (const slot of schedule.trainSet?.wagons ?? []) { + const frozenSlot = isWagonAllocationFrozen + ? snapshotSlotByTrainSetWagonId.get(slot.id) + : undefined; + const physicalId = frozenSlot + ? frozenSlot.physicalWagonId + : slot.physicalWagonId ?? null; + if (physicalId) coveredPhysicalIds.add(physicalId); + } + const maxSlotSequenceNo = Math.max( + 0, + ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo), + ); + const emptyConsistWagons = + schedule.trainSet?.trainId && !isWagonAllocationFrozen + ? ( + await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }) + ) + .filter((wagon) => !coveredPhysicalIds.has(wagon.id)) + .map((wagon, index) => ({ + // Physical wagon id — there is no TrainSetWagon slot behind this + // row, so remove/edit affordances must stay disabled (consistOnly). + id: wagon.id, + sequenceNo: maxSlotSequenceNo + index + 1, + capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)), + assignedWeightTons: 0, + tareWeightTons: wagon.wagonType + ? roundTons(Number(wagon.wagonType.tareWeightTons)) + : null, + status: 'EMPTY', + physicalWagonId: wagon.id, + physicalWagonNumber: wagon.wagonNumber ?? null, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: [], + consistOnly: true, + })) + : []; + return { id: schedule.id, reference: schedule.reference ?? null, @@ -6109,7 +6284,8 @@ export class TrainSchedulingService { : null, })) ?? [], }; - }), + }) + .concat(emptyConsistWagons), } : null, bookings: diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 499e40f2e..e92808274 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -421,6 +421,20 @@ export class WarehouseInventoryController { return res.send(buffer); } + @Get('edr-truck-exit-paper/:assignmentId') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' }) + async edrTruckExitPaper( + @Param('assignmentId', ParseUUIDPipe) assignmentId: string, + @Res() res: Response, + ) { + const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get(':id/grn-document') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View goods received note PDF' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 6dd941d26..85311f049 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2821,6 +2821,16 @@ export class WarehouseInventoryService { : dto; const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); + // The load actually leaving on this truck, in TONNES (the weighing UI is in + // t). Null when the operator skipped weighing — containers may skip, bulk + // never does. + const grossTons = exitInspectionDto.grossWeight ?? null; + const tareTons = exitInspectionDto.tareWeight ?? null; + const netTons = + grossTons != null && tareTons != null + ? Math.round((grossTons - tareTons) * 1000) / 1000 + : (exitInspectionDto.netWeight ?? null); + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, @@ -2848,6 +2858,23 @@ export class WarehouseInventoryService { // an EXPORT concept (set when a truck delivers into the port). Import // load + weight are captured on truck departure, not arrival. } + // EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than + // container so it works for bulk too (bulk trucks carry no container). + if (dto.truckPlateNumber?.trim()) { + await manager.query( + `UPDATE freight.last_mile_vehicle_assignments va + SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW() + FROM freight.last_mile lm, freight.vehicles v + WHERE va.last_mile_id = lm.id + AND lm.booking_id = $1 + AND lm.deleted_at IS NULL + AND v.id = va.vehicle_id + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) + AND va.arrived_at IS NULL + AND va.deleted_at IS NULL`, + [item.bookingId, dto.truckPlateNumber.trim()], + ); + } // Booking-level flag stamped on the FIRST truck arrival. The import // handover is signed ONCE (before the first truck leaves), even though // trucks pick up per-container — COALESCE keeps the first timestamp. @@ -2868,6 +2895,34 @@ export class WarehouseInventoryService { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } + if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) { + // EDR last-mile: this truck is leaving — record its exit and the load it + // actually took. net_weight_tons drives the bulk drawdown (booking VGM + // minus everything already hauled away). + await manager.query( + `UPDATE freight.last_mile_vehicle_assignments va + SET departed_at = COALESCE($3::timestamptz, NOW()), + arrived_at = COALESCE(va.arrived_at, NOW()), + gross_weight_tons = $4, + net_weight_tons = $5, + updated_at = NOW() + FROM freight.last_mile lm, freight.vehicles v + WHERE va.last_mile_id = lm.id + AND lm.booking_id = $1 + AND lm.deleted_at IS NULL + AND v.id = va.vehicle_id + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) + AND va.departed_at IS NULL + AND va.deleted_at IS NULL`, + [ + item.bookingId, + dto.truckPlateNumber.trim(), + dto.gateOutTime ?? null, + grossTons, + netTons, + ], + ); + } await this.activityLog.record( { activityType: 'INVENTORY_RELEASED', @@ -2886,9 +2941,56 @@ export class WarehouseInventoryService { ); }); + // Tell the customer their truck has left — one hook covers BOTH self-haul and + // EDR last-mile, since release() is the single exit path for either. Outside + // the transaction and fire-and-forget: notifying must never fail the exit. + if (isTruckLeaving && item.bookingId) { + void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } + return this.findById(id); } + /** + * Best-effort truck-departure notification to the booking's company across + * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing + * provider or contact must not break the exit flow. + */ + private async notifyTruckDeparture( + bookingId: string, + plateNumber: string | null, + netTons: number | null, + ): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : ''; + const body = `${truck} has left the warehouse for booking ${ref}${load}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck left the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn( + `Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`, + ); + } + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3213,6 +3315,75 @@ export class WarehouseInventoryService { }; } + /** + * Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle + * assignment). Deliberately NOT gated on the handover: EDR handovers are + * generated at delivery — i.e. after the truck has already left — so there is + * nothing to sign at exit time. Warehouse-fee clearance still applies. + */ + async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> { + const [truck] = await this.dataSource.query( + `SELECT lm.booking_id AS "bookingId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE( + v.assigned_driver_name, + NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '') + ) AS "driverName", + v.vehicle_type AS "truckType", + va.gross_weight_tons AS "grossWeightKg", + va.departed_at AS "departedAt", + b.reference AS "bookingReference", + company.name AS "customerName" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE va.id = $1 AND va.deleted_at IS NULL`, + [assignmentId], + ); + if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`); + + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`, + [truck.bookingId], + ); + if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); + + // Bulk trucks carry no containers — the table is then empty and the paper + // stands on the weighed gross alone. + const containers: Array<{ containerNumber: string; goods: string | null }> = + await this.dataSource.query( + `SELECT vc.container_number AS "containerNumber", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + FROM freight.last_mile_vehicle_containers vc + JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL + ORDER BY vc.container_number`, + [assignmentId], + ); + + const html = this.buildTruckExitPaperHtml({ + reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, + bookingReference: truck.bookingReference, + customerName: truck.customerName, + plateNumber: truck.plateNumber, + driverName: truck.driverName ?? '-', + truckType: truck.truckType ?? '-', + grossWeightKg: Number(truck.grossWeightKg ?? 0), + gateOut: truck.departedAt, + containers, + }); + return { + filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'), + }; + } + private buildTruckExitPaperHtml(data: { reference: string; bookingReference: string; @@ -3728,20 +3899,30 @@ export class WarehouseInventoryService { ); } else { // EDR last-mile: the handover is per delivering truck. Resolve the - // vehicle that carried this item's container so each truck gets its own - // handover (falls back to a booking-level one when unresolvable). + // vehicle from the truck's own container list (the earlier lookup went + // through last_mile_container_allocations, which nothing ever writes — + // so truckPlate was always null and every booking collapsed to a single + // booking-level handover). Bulk has no container, so fall back to the + // delivery's single truck; a booking-level handover when unresolvable. let truckPlate: string | null = null; - if (item.containerId) { - const [veh]: Array<{ plate: string | null }> = await manager.query( - `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate - FROM freight.last_mile_container_allocations lca - JOIN freight.vehicles v ON v.id = lca.vehicle_id - WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL - LIMIT 1`, - [item.containerId], - ); - truckPlate = veh?.plate ?? null; - } + const [veh]: Array<{ plate: string | null }> = await manager.query( + `SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm + ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.containers cont + ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL + WHERE lm.booking_id = $1 + AND va.deleted_at IS NULL + AND ($2::uuid IS NULL OR cont.id = $2::uuid) + ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC + LIMIT 1`, + [item.bookingId, item.containerId ?? null], + ); + truckPlate = veh?.plate ?? null; await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager); } } diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index a5e34a35d..c840d18a1 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -4,3 +4,8 @@ VITE_BASE_API_URL=http://localhost:3001 # Proactive token refresh cadence (minutes). Must stay well under the 60-min # server session window. Default: 10. VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10 + +# PostHog — session replay, error tracking, console logs. Both must be set or +# observability stays off (the app works either way). Self-hosted instance. +VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +VITE_POSTHOG_HOST=https://posthog.example.com diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 2f2e0d7be..9f7fd27bf 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -20,6 +20,7 @@ "@mantine/core": "^9.3.0", "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@posthog/react": "^1.10.3", "@radix-ui/react-accordion": "^1.2.13", "@radix-ui/react-alert-dialog": "^1.1.16", "@radix-ui/react-avatar": "^1.1.12", @@ -76,6 +77,7 @@ "lucide-react": "^1.14.0", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", + "posthog-js": "^1.400.1", "prop-types": "^15.8.1", "qs": "^6.15.2", "radix-ui": "^1.4.3", diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx index 8266d96d7..ff16abd94 100644 --- a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from "react"; +import { useIdentify } from "@/lib/posthog"; import { getMeRequest, loginRequest, verifyMfaRequest } from "./api"; import { AUTH_TOKEN_COOKIE, @@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const [loading, setLoading] = useState(true); const mfaEmailRef = useRef(null); + // Attribute replays and exceptions to the signed-in user (id/org only). + useIdentify(user); + const loadCurrentUser = async () => { const currentUser = await getMeRequest(); setUser(currentUser); diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 39bb6cab7..e43351015 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -5,6 +5,7 @@ import { emitApiError, extractApiErrorPayload, } from "@/components/errors/ApiErrorModal"; +import { captureApiError } from "@/lib/posthog"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, @@ -82,6 +83,14 @@ api.interceptors.response.use( async (error) => { const originalRequest = error.config as RetriableRequest | undefined; + // Report the failure to PostHog. Hooked here rather than inside + // `emitApiError`, which stays silent on suppressed paths (warehouse / + // mile / onboarding) — those failures still need reporting. + // 401s are skipped: an expired session is refreshed below, not a defect. + if (!error.response || error.response.status !== 401) { + captureApiError(error); + } + if ( error.response?.status !== 401 || !originalRequest || diff --git a/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx index 00635118d..1c502e758 100644 --- a/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ErrorBoundary.tsx @@ -1,5 +1,7 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { captureException } from "@/lib/posthog"; + interface ErrorBoundaryProps { children: ReactNode; } @@ -21,6 +23,7 @@ export class ErrorBoundary extends Component { + if (!isContainer) return 0; + return containerLines + .filter((l) => parseInt(l.containerSize, 10) === 20) + .reduce((sum, l) => sum + Number(l.quantity || 0), 0); + }, [isContainer, containerLines]); + const hasOdd20ft = ft20Total % 2 === 1; + const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON"; const bulkErrors = useMemo(() => { @@ -688,7 +703,7 @@ export default function GlCreateBookingForm() { ) : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - const formValid = cargoValid && !dateError && !routeError; + const formValid = cargoValid && !hasOdd20ft && !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 @@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() { )) )} + + {hasOdd20ft ? ( + } + title={`Odd number of 20ft containers (${ft20Total})`} + > + 20ft containers travel two per wagon, so they must be booked in + even numbers. Add one more 20ft container or remove one (e.g. + book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}) + — the booking cannot be created with an unpaired 20ft container. + + ) : null} ) : ( @@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() { ) : null} - + {/* Mantine tooltips get no pointer events from a disabled button, + so the wrapper carries the hover target. */} + + + + diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx index 80f72d9d0..ecaa43161 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PinWagonsForm.tsx @@ -36,7 +36,9 @@ export function PinWagonsForm({ autoFillOnMount?: boolean; }) { const originYardId = schedule.originStation?.id; - const slots = schedule.trainSet?.wagons ?? []; + // Consist-only rows are the built train's coupled-but-empty wagons — display + // entries with no TrainSetWagon slot behind them, so nothing can be pinned. + const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly); const [assignments, setAssignments] = useState>({}); const wagonOptionsByType = useMemo(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index ee69fe145..8af9a6e34 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -359,7 +359,9 @@ function WagonCar({ {isEmpty ? ( - Empty slot — available for allocation. + {wagon.consistOnly + ? "Empty wagon — coupled on the train, no load planned." + : "Empty slot — available for allocation."} ) : ( diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx index 663ab2e6f..f735e6474 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -167,9 +167,9 @@ export const WagonCard = ({ - Empty slot + {wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"} - {!isDispatched ? ( + {!isDispatched && !wagon.consistOnly ? ( + ) : null} + + + ) : bookingAlreadyCreated ? ( + } + > + Payment expired + + + ); + } if (row.bookingCreated) { return ( @@ -519,6 +548,27 @@ export default function ContractClearanceListPage() { Create booking + ) : row.original.paymentExpired && canCreateBooking ? ( + + + ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx new file mode 100644 index 000000000..b35af1e7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import { Alert, Badge, Button, Modal, Stack, Table, Text } from "@mantine/core"; +import { FileText } from "lucide-react"; + +import { useToast } from "@/hooks/use-toast"; +import { warehouseService } from "@/services/warehouse.service"; +import type { LastMileRecord } from "@/services/last-mile.service"; +import { extractDownloadErrorMessage } from "@/components/warehouses/options"; +import { openPdfBlob } from "@/components/warehouses/pdf"; + +interface EdrTruckExitPapersModalProps { + opened: boolean; + onClose: () => void; + record: LastMileRecord | null; +} + +const fmt = (value?: string | null) => + value ? new Date(value).toLocaleString() : "—"; + +/** + * Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has + * its own arrival, exit and weighed load, so each gets its own paper. + */ +export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExitPapersModalProps) { + const { toast } = useToast(); + const [busyId, setBusyId] = useState(null); + const trucks = record?.vehicleAssignments ?? []; + + const download = async (assignmentId: string, plate: string) => { + setBusyId(assignmentId); + try { + const res = await warehouseService.downloadEdrTruckExitPaper(assignmentId); + openPdfBlob(res.data, `exit-${plate || assignmentId}.pdf`); + } catch (e) { + toast({ + variant: "destructive", + title: "Exit paper not ready", + description: await extractDownloadErrorMessage(e), + }); + } finally { + setBusyId(null); + } + }; + + return ( + + Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""} + + } + > + {trucks.length === 0 ? ( + + No trucks assigned to this delivery yet. + + ) : ( + + + + + Truck + Containers + Arrived + Left + Net + Exit paper + + + + {trucks.map((t) => { + const plate = t.vehicle?.powerPlateNo || t.vehicle?.plateNumber || "—"; + const load = t.containers?.length + ? t.containers.map((c) => c.containerNumber).join(", ") + : (t.containerNumber ?? "bulk"); + return ( + + + {plate} + + + {load} + + + {fmt(t.arrivedAt)} + + + {t.departedAt ? ( + {fmt(t.departedAt)} + ) : ( + + Still on site + + )} + + + + {t.netWeightTons != null ? `${t.netWeightTons} t` : "—"} + + + + + + + ); + })} + +
+ + EDR handovers are generated at delivery, so an exit paper is not gated on a + signature — warehouse-fee clearance still applies. + +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index cd5d22c6b..3f96edd2f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -9,6 +9,7 @@ import { RefreshCw, Ruler, Trash, + FileText, Truck, X, } from "lucide-react"; @@ -56,6 +57,7 @@ import { import { vehiclesService } from "@/services/vehicles.service"; import { driversService, type Driver } from "@/services/drivers.service"; import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal"; +import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal"; import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal"; import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps"; @@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) => (sum, c) => sum + (Number(c.quantity) || 0), 0, ); -/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */ +/** + * Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each), + * two 20ft share one. Falls back to ceil(n / 2) when no size is recorded. + * 0 when the booking has no container data (bulk). + */ const requiredVehicles = (record: LastMileRecord) => { - const n = containerCount(record); - return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0; + const lines = record.booking?.bookingContainers ?? []; + if (!containerCount(record)) return 0; + let forty = 0; + let others = 0; + for (const c of lines) { + const qty = Number(c.quantity) || 0; + if ((c.containerSize ?? '').includes('40')) forty += qty; + else others += qty; + } + return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE); }; /** Real per-physical-container numbers on a booking, in order. Prefers each @@ -581,9 +595,11 @@ const LastMilePage = () => { const [activeId, setActiveId] = useState(null); const [detentionRecord, setDetentionRecord] = useState(null); // Multi-vehicle assign: one row per truck — vehicle + the container it carries. + // One row per truck. A truck carries one 40ft or up to two 20ft, so the load + // is a list, not a single container. const [vehicleRows, setVehicleRows] = useState< - Array<{ vehicleId: string | null; containerNumber: string }> - >([{ vehicleId: null, containerNumber: "" }]); + Array<{ vehicleId: string | null; containerNumbers: string[] }> + >([{ vehicleId: null, containerNumbers: [] }]); // 2-step "Assign Mile" accept modal (arrival queue → vehicle) const [acceptOpen, setAcceptOpen] = useState(false); @@ -1000,31 +1016,59 @@ const LastMilePage = () => { return filteredRecords.slice(start, start + pagination.pageSize); }, [filteredRecords, pagination]); + // Per-truck exit papers for an EDR delivery (one paper per assigned truck). + const [exitPapersOpen, setExitPapersOpen] = useState(false); + const [exitPapersRecord, setExitPapersRecord] = useState(null); + + // Bulk drawdown: how much tonnage is still to be hauled on the booking being + // assigned. Bulk has no containers, so trucks keep going until this hits 0. + const assignBookingId = activeRecord?.booking?.id ?? null; + const { data: remainingTons } = useQuery({ + queryKey: ["last-mile", "remaining-tons", assignBookingId], + queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data), + enabled: assignOpen && !bulkMode && Boolean(assignBookingId), + }); + const openAssign = (id: string | null) => { const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null; const rec = records.find((r) => r.id === resolved); // Prefill each row's container number from the booking's container numbers // (by order) when the assignment doesn't already carry one. const nums = rec ? bookingContainerNumbers(rec) : []; + // Prefer the truck's own container list; fall back to the legacy scalar, then + // to the booking's containers by order. + const loadOf = ( + a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null }, + i: number, + ) => + a.containers?.length + ? a.containers.map((c) => c.containerNumber) + : a.containerNumber + ? [a.containerNumber] + : nums[i] + ? [nums[i]] + : []; const rows = rec?.vehicleAssignments?.length ? rec.vehicleAssignments.map((a, i) => ({ vehicleId: a.vehicleId, - containerNumber: a.containerNumber ?? nums[i] ?? "", + containerNumbers: loadOf(a, i), })) : rec?.vehicleId - ? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }] - : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]; + ? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }] + : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }]; setBulkMode(false); setActiveId(resolved); - setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]); + setVehicleRows( + rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }], + ); setAssignOpen(true); }; const openBulkAssign = () => { setBulkMode(true); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); setAssignOpen(true); }; @@ -1032,15 +1076,18 @@ const LastMilePage = () => { setAssignOpen(false); setBulkMode(false); setActiveId(null); - setVehicleRows([{ vehicleId: null, containerNumber: "" }]); + setVehicleRows([{ vehicleId: null, containerNumbers: [] }]); }; const handleAssign = () => { const seen = new Set(); const vehicles = vehicleRows - .filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId)) + .filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId)) .filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId))) - .map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null })); + .map((r) => ({ + vehicleId: r.vehicleId, + containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean), + })); const count = vehicles.length; const targetIds = bulkMode ? selectedIds @@ -1419,6 +1466,16 @@ const LastMilePage = () => { > Truck Leaving + } + disabled={!row.original.vehicleAssignments?.length} + onClick={() => { + setExitPapersRecord(row.original); + setExitPapersOpen(true); + }} + > + Truck exit papers + } @@ -1738,9 +1795,24 @@ const LastMilePage = () => { const needed = requiredVehicles(activeRecord); const picked = vehicleRows.filter((r) => r.vehicleId).length; if (needed === 0) { + // Bulk: no containers — trucks haul loose tonnage until the + // booking's total is drawn down to zero by departing trucks. + const done = remainingTons?.complete; return ( - - No container count on this booking — assign trucks as needed. + + {remainingTons + ? done + ? "Fully hauled — no tonnage left to assign trucks for." + : `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.` + : "Assign trucks as needed."} ); } @@ -1799,25 +1871,27 @@ const LastMilePage = () => { clearable disabled={assignVehicleOptions.length === 0} /> - setForm({ ...form, bookingRef: e.target.value })} /> + +
+ + setForm({ ...form, amountEtb: e.target.value })} /> +
+
+ + +
+
+ +