diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index cf4c37b2d..f13c596d2 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -1,6 +1,7 @@ import "reflect-metadata"; import * as dotenv from "dotenv"; dotenv.config(); +import { createRequire } from "node:module"; import { NestFactory } from "@nestjs/core"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; @@ -20,6 +21,62 @@ import { AppModule } from "./app.module"; */ const JSON_BODY_LIMIT = '20mb'; +/** + * Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as + * `host=ip,host2=ip2`. Some internal hosts (MinIO) resolve only inside the + * deployment network, so dev machines get ENOTFOUND on every upload. Patching + * `dns.lookup` keeps the real hostname on the wire — the IP is used for the + * connection only — so TLS still validates against the certificate's CN. + * + * The module is loaded through `createRequire`, NOT `import * as dns`: an ESM + * namespace object is frozen, so assigning to it is silently dropped and the + * patch becomes a no-op. `require` returns the live module object every other + * caller (minio's http agent included) reads `lookup` off. + */ +function applyDnsHostOverrides(): void { + const raw = process.env.DNS_HOST_OVERRIDES?.trim(); + if (!raw) return; + + const overrides = new Map(); + for (const entry of raw.split(",")) { + const [host, ip] = entry.split("=").map((part) => part?.trim()); + if (host && ip) overrides.set(host.toLowerCase(), ip); + } + if (overrides.size === 0) return; + + const dns = createRequire(__filename)("node:dns") as typeof import("node:dns"); + const originalLookup = dns.lookup.bind(dns); + // `dns.lookup` is overloaded (options optional, all/family variants); the + // cast keeps that surface intact while we intercept only mapped hostnames. + (dns as { lookup: unknown }).lookup = (( + hostname: string, + options: unknown, + callback?: unknown, + ) => { + const ip = overrides.get(hostname?.toLowerCase?.()); + if (!ip) return (originalLookup as Function)(hostname, options, callback); + + const done = (typeof options === "function" ? options : callback) as ( + err: NodeJS.ErrnoException | null, + address: string | { address: string; family: number }[], + family?: number, + ) => void; + const family = ip.includes(":") ? 6 : 4; + const wantsAll = + typeof options === "object" && options !== null && (options as { all?: boolean }).all; + + process.nextTick(() => + wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family), + ); + }) as typeof dns.lookup; + + console.log( + `[DNS] Host overrides active: ${[...overrides].map(([h, ip]) => `${h}->${ip}`).join(", ")}`, + ); +} + +applyDnsHostOverrides(); + async function bootstrap() { const app = await NestFactory.create(AppModule); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b16febe2e..78004f024 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1289,6 +1289,38 @@ export class BookingsRepository extends BaseRepository { .getMany(); } + /** + * EXPIRED bookings on the day's corridor — the batch board's expired lane. + * Expiry nulls train_schedule_id, so neither findAllBySchedule nor the + * ready-pool query can ever see them. + */ + findExpiredByCorridorDay( + corridorYardIds: string[], + day: string, + ): Promise { + if (corridorYardIds.length === 0) return Promise.resolve([]); + return this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') + .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') + .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) + .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { + corridorYardIds, + }) + .andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`, + { day }, + ) + .andWhere('sb.id IS NULL') + .andWhere(`booking.status = 'EXPIRED'`) + .orderBy('booking.priority_score', 'DESC') + .addOrderBy('booking.created_at', 'ASC') + .getMany(); + } + /** * Commercial bookings on the day's corridor whose operation request was NOT * accepted by staff (still pending / changes / price-confirm) and are not yet 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 7eb87bf86..8da28e400 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 @@ -290,6 +290,7 @@ export class ContractBookingService { isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, @@ -773,6 +774,7 @@ export class ContractBookingService { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), // Completion is where the cargo — and therefore the price — is fixed, so // it is also where the billing currency is chosen. A bare instance was @@ -1253,6 +1255,7 @@ export class ContractBookingService { } probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); + probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto); const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { @@ -1297,11 +1300,7 @@ export class ContractBookingService { return; } - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const remaining = outstanding.bulk?.outstanding ?? 0; // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights. if (Math.abs(requested - remaining) > 0.001) { @@ -1344,8 +1343,10 @@ export class ContractBookingService { } } } else { + // PER_ITEM contracts are capped in items, so the item count is the + // consumption figure — tonnage is only wagon-sizing data. const requested = - (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; + Number(lines.bulk?.itemCount ?? lines.bulk?.cargoWeightTons ?? 0) || 0; const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1373,11 +1374,7 @@ export class ContractBookingService { } } } else { - const requested = - (dto.bulkLines ?? []).reduce( - (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), - 0, - ) || this.resolveBulkTons(dto) || 0; + const requested = this.resolveBulkTons(dto); const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( @@ -1641,11 +1638,27 @@ export class ContractBookingService { private resolveBulkTons(dto: CreateBookingUnderContractDto): number { if (!dto.bulkLines?.length) return 0; return dto.bulkLines.reduce( - (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0), + (sum, l) => sum + Number(l.itemCount ?? l.cargoWeightTons ?? 0), 0, ); } + /** + * Real tonnage of a PER_ITEM (break-bulk) booking, kept alongside the item + * count `cargoTotalWeightVgm` holds. Both are needed: the item count prices + * the booking, the tonnage sizes the wagons (`bulkItemWagonsRequired` derives + * per-item weight from tonnage ÷ items). Null for PER_TON bulk, where + * `cargoTotalWeightVgm` already IS the tonnage. + */ + private resolveBulkWeightTons( + dto: CreateBookingUnderContractDto, + ): number | null { + const lines = dto.bulkLines ?? []; + if (!lines.some((l) => Number(l.itemCount) > 0)) return null; + const tons = lines.reduce((sum, l) => sum + Number(l.cargoWeightTons ?? 0), 0); + return tons > 0 ? tons : null; + } + /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however @@ -1961,6 +1974,7 @@ export class ContractBookingService { originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), + bulkTotalWeightTons: this.resolveBulkWeightTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => 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 5203c3a14..3d3b992d3 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 @@ -1632,6 +1632,18 @@ export class BookingBatchService implements OnModuleInit { for (const b of candidates) { if (!pinnedIds.has(b.id)) bookings.push(b); } + // Expiry frees the schedule pin (expire() nulls train_schedule_id), so + // expired bookings match neither query above — merge them back so the + // board keeps its expired lane. Display-only: boardState maps them to + // EXPIRED, which every capacity meter already excludes. + const expiredPool = + await this.bookingsRepository.findExpiredByCorridorDay( + stops, + eatDay(s.scheduledDepartureDate), + ); + for (const b of expiredPool) { + if (!pinnedIds.has(b.id)) bookings.push(b); + } } catch (err) { // The board must still render the pinned bookings. this.logger.warn( @@ -3131,6 +3143,14 @@ export class BookingBatchService implements OnModuleInit { async intercityCapacity(scheduleId: string): Promise<{ budget: CorridorBudget; needFor: (booking: Booking) => Capacity; + /** + * Per-wagon-type split of `needFor(booking).wagons`, against THIS + * schedule's own wagon stock — so the same booking reads differently on a + * different train. Empty when the stock can't be resolved. + */ + breakdownFor: ( + booking: Booking, + ) => Array<{ wagonTypeId: string; code: string; wagons: number }>; } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -3145,7 +3165,24 @@ export class BookingBatchService implements OnModuleInit { // still accepts ride-alongs on its empty legs — that is the whole point // of the ride-along flow. const budget = await this.remainingBudget(schedule, limits, wagonDims); - return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; + // Physical stock of THIS schedule's train (built consist, or the yard fleet + // it will draw from) — what makes the breakdown train-specific. + const stock = await this.trainSchedulingService.wagonStockForSchedule( + schedule.id, + schedule.originStationId, + budget.stops, + ); + return { + budget, + needFor: (booking) => this.needFor(booking, wagonDims), + breakdownFor: (booking) => + this.wagonBreakdownFor( + booking, + wagonDims, + stock.remainingByTypeId, + stock.codesByTypeId, + ), + }; } /** @@ -4094,6 +4131,78 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * The wagon count of {@link wagonsFor}, split across the wagon TYPES this + * particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4. + * + * `wagonsFor` sizes the booking on ONE representative type (the first the + * cargo type allows), which is all the abstract budget needs. Staff placing a + * ride-along need the physical picture: how many of each type this schedule + * must actually give up. So each allowed type is sized on its OWN capacity and + * items-fit, then filled greedily from the type with the largest per-wagon + * take, bounded by what the schedule has left of it. + * + * Because the stock is per-schedule, the same booking breaks down differently + * on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when + * the booking's types are unconfigured or the train stocks none of them — the + * caller then shows the plain total. + */ + private wagonBreakdownFor( + booking: Booking, + wagonDims: WagonDims, + stockByTypeId: Map, + codesByTypeId: Map, + ): Array<{ wagonTypeId: string; code: string; wagons: number }> { + const total = this.wagonsFor(booking, wagonDims); + if (total <= 0) return []; + + // Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type + // that swallows more of the booking per wagon needs fewer wagons. + const options = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) + .map((o) => { + const wagonTypeId = o.wagonTypeId as string; + const wagonsIfAlone = Math.max( + 1, + bulkItemWagonsRequired( + booking, + o.dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ) || + (o.dims.capacityTons > 0 + ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + : total), + ); + return { + wagonTypeId, + code: codesByTypeId.get(wagonTypeId) ?? '—', + available: stockByTypeId.get(wagonTypeId) ?? 0, + // Share of the whole booking one wagon of this type carries. + takePerWagon: 1 / wagonsIfAlone, + }; + }) + .sort((a, b) => b.takePerWagon - a.takePerWagon); + if (!options.length) return []; + + // Fill greedily by take, capped by stock; `remaining` is the fraction of the + // booking still unplaced, so a wagon of any type covers `takePerWagon` of it. + const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = []; + let remaining = 1; + for (const option of options) { + if (remaining <= 1e-9) break; + const wagons = Math.min( + option.available, + Math.ceil(remaining / option.takePerWagon), + ); + if (wagons <= 0) continue; + out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons }); + remaining -= wagons * option.takePerWagon; + } + // The train cannot hold the whole booking in the types it stocks — the + // `fits` check already fails it; report only what it CAN take. + return out; + } + private fits(need: Capacity, budget: Capacity): boolean { return ( need.wagons <= budget.wagons && diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts new file mode 100644 index 000000000..c1ea5b8a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.wagon-breakdown.spec.ts @@ -0,0 +1,108 @@ +import { BookingBatchService } from './booking-batch.service'; +import { Booking } from '../bookings/entities/booking.entity'; + +/** + * The intercity ride-along board shows WHICH wagon types a booking takes from + * the train it is being placed on ("3 × N35 + 1 × PW2"), not just how many + * wagons. Because the split is drawn against that schedule's own stock, the + * same booking must read differently on a different train. + */ +describe('BookingBatchService — intercity wagon breakdown', () => { + const N35 = 'wagon-type-n35'; + const PW2 = 'wagon-type-pw2'; + + const dims = (capacityTons: number) => ({ + capacityTons, + lengthMeters: 14, + tareWeightTons: 20, + }); + + const wagonDims = { + bulk: dims(60), + container: dims(60), + byWagonTypeId: new Map([ + [N35, dims(60)], + [PW2, dims(20)], + ]), + }; + + const codes = new Map([ + [N35, 'N35'], + [PW2, 'PW2'], + ]); + + /** + * 400 break-bulk items weighing 800t — 2t per item. On a 60t N35 that is 30 + * items per wagon (14 wagons); on a 20t PW2, 10 items (40 wagons). + */ + const perItemBooking = { + id: 'booking-1', + freightType: 'BULK', + cargoTotalWeightVgm: 400, + bulkTotalWeightTons: 800, + bookingContainers: [], + cargoType: { + wagonTypes: [{ id: N35 }, { id: PW2 }], + itemsPerWagonMap: {}, + }, + } as unknown as Booking; + + const service = Object.create( + BookingBatchService.prototype, + ) as BookingBatchService; + + const breakdown = ( + booking: Booking, + stock: Map, + ): Array<{ code: string; wagons: number }> => + ( + service as unknown as { + wagonBreakdownFor: ( + b: Booking, + d: typeof wagonDims, + s: Map, + c: Map, + ) => Array<{ code: string; wagons: number }>; + } + ) + .wagonBreakdownFor(booking, wagonDims, stock, codes) + .map(({ code, wagons }) => ({ code, wagons })); + + it('takes the highest-capacity type first when the train stocks plenty', () => { + const rows = breakdown(perItemBooking, new Map([[N35, 50], [PW2, 50]])); + expect(rows).toEqual([{ code: 'N35', wagons: 14 }]); + }); + + it('falls back to the smaller type for the remainder when the big one runs short', () => { + // Only 10 of the 14 N35s the booking wants — the rest rides PW2s. Ten N35s + // carry 10/14 of the booking, leaving 4/14, which needs ceil(40 × 4/14) PW2s. + const rows = breakdown(perItemBooking, new Map([[N35, 10], [PW2, 50]])); + expect(rows[0]).toEqual({ code: 'N35', wagons: 10 }); + expect(rows[1].code).toBe('PW2'); + expect(rows[1].wagons).toBeGreaterThan(0); + }); + + it('reads differently on a train that stocks only the small type', () => { + const rows = breakdown(perItemBooking, new Map([[PW2, 60]])); + expect(rows).toEqual([{ code: 'PW2', wagons: 40 }]); + }); + + it('honours the configured items-per-wagon fit over raw tonnage', () => { + // Floor space binds before weight: an N35 physically holds 20 of these + // items even though 30 would fit by weight → 20 wagons, not 14. + const floorBound = { + ...perItemBooking, + cargoType: { + wagonTypes: [{ id: N35 }], + itemsPerWagonMap: { [N35]: 20 }, + }, + } as unknown as Booking; + expect(breakdown(floorBound, new Map([[N35, 50]]))).toEqual([ + { code: 'N35', wagons: 20 }, + ]); + }); + + it('returns nothing when the train stocks none of the allowed types', () => { + expect(breakdown(perItemBooking, new Map([['other-type', 30]]))).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 5775148d7..4e2724f49 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -155,12 +155,17 @@ export class IntercityService { return { ...this.mapBooking(booking, need), need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)), }; }), accepted: accepted.map((booking) => { const need = capacity?.needFor(booking) ?? null; - return { ...this.mapBooking(booking, need), need }; + return { + ...this.mapBooking(booking, need), + need, + wagonBreakdown: capacity?.breakdownFor(booking) ?? [], + }; }), }; } @@ -200,7 +205,9 @@ export class IntercityService { where: { id: bookingId }, relations: { bookingContainers: { containerType: true }, - cargoType: true, + // wagonTypes drives the break-bulk items-per-wagon fit — the accept + // check must size the booking exactly as the candidate list did. + cargoType: { wagonTypes: true }, }, }); if (!booking) { @@ -326,6 +333,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -355,6 +366,10 @@ export class IntercityService { .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') .leftJoinAndSelect('booking.cargoType', 'cargoType') + // The allowed wagon-type list is what sizes a break-bulk (PER_ITEM) + // booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and + // the wagon count silently degrades to tonnage-only. + .leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 80030c6c3..406f51988 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1435,4 +1435,44 @@ describe('TrainSchedulingService', () => { ).rejects.toThrow(/free only 5/); }); }); + + describe('effectiveWagonsRequired', () => { + const effective = (booking: unknown): number => + (service as never as { effectiveWagonsRequired(b: unknown): number }) + .effectiveWagonsRequired(booking); + + // 20-item / 100T break-bulk on 70T wagons with a 4-items-per-wagon fit: + // ceil(20/4) = 5 wagons. + const perItemBooking = (wagonsRequired: number | null) => ({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired, + cargoType: { + wagonTypes: [{ id: 'wt-nw5', capacityTons: 70 }], + itemsPerWagonMap: { 'wt-nw5': 4 }, + }, + }); + + it('overrides a stale too-small stamp with the item-aware recompute', () => { + // Stamped 1 by old code that read the PER_ITEM count (20) as tons. + expect(effective(perItemBooking(1))).toBe(5); + }); + + it('keeps a stored stamp that is at least the recompute', () => { + expect(effective(perItemBooking(7))).toBe(7); + }); + + it('trusts the stamp when BULK cargo relations are not loaded', () => { + expect( + effective({ + freightType: 'BULK', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + wagonsRequired: 5, + cargoType: null, + }), + ).toBe(5); + }); + }); }); 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 a23e763f2..9c60c9821 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 @@ -136,6 +136,8 @@ import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsRequired, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -7335,7 +7337,14 @@ export class TrainSchedulingService { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const byWeight = cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; - const wagons = Math.max(1, stored, byLength, byWeight); + // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw + // tonnage suggests — their tare must be pulled too (batch dimsFor parity). + const byItems = bulkItemWagonsRequired( + booking, + dims.capacityTons, + bulkItemsFitFor(booking.cargoType, wagonTypeId), + ); + const wagons = Math.max(1, stored, byLength, byWeight, byItems); return roundTons(cargo + wagons * dims.tareWeightTons); } @@ -7819,7 +7828,7 @@ export class TrainSchedulingService { */ private effectiveWagonsRequired(booking: Booking): number { const stored = Number(booking.wagonsRequired); - if (stored > 0) return Math.ceil(stored); + const storedCeil = stored > 0 ? Math.ceil(stored) : 0; const bulkCapacities = (booking.cargoType?.wagonTypes ?? []) .map((wt) => Number(wt.capacityTons)) .filter((c) => c > 0); @@ -7827,7 +7836,15 @@ export class TrainSchedulingService { booking.freightType === 'BULK' && bulkCapacities.length ? Math.max(...bulkCapacities) : undefined; - return wagonsRequiredForBooking(booking, bulkCapacity); + // BULK with no cargo relations loaded: recomputing would size against a + // 1T capacity and read a PER_ITEM item count as tons — trust the stamp. + if (booking.freightType === 'BULK' && bulkCapacity === undefined && storedCeil > 0) { + return storedCeil; + } + // Stored is a candidate, never an early return (batch parity): rows + // stamped while BULK sizing read the PER_ITEM item count as tons carry a + // too-small footprint — a 20-item/100T booking was stamped 1 wagon. + return Math.max(storedCeil, wagonsRequiredForBooking(booking, bulkCapacity)); } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 6bafcc730..8d9339186 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -315,3 +315,131 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => expect(result.plan).toHaveLength(1); }); }); + +describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', () => { + const pw2: WagonType = { + id: 'wt-pw2', + code: 'PW2', + capacityTons: 70, + lengthMeters: 17, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + + // 20 machinery items, 100T total (5T each). NW5 fits 4/wagon, PW2 fits 3. + const machineryBooking = (): Booking => + ({ + id: 'BULK-ITEMS', + reference: 'BULK-ITEMS', + freightType: 'BULK', + cargoTypeId: 'ct-machinery', + cargoTotalWeightVgm: 20, + bulkTotalWeightTons: 100, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 4, 'wt-pw2': 3 }, + wagonTypes: [pw2, nw5], + }, + }) as unknown as Booking; + + const allowed = { + byContainerTypeId: new Map(), + byCargoTypeId: new Map([['ct-machinery', [pw2, nw5]]]), + }; + + it('packs whole items per wagon by the items-fit map, not raw tonnage', () => { + const result = planWagonsWithStock({ + bookings: [machineryBooking()], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + // Best fit: NW5 at 4 items/wagon → ceil(20/4) = 5 wagons, 20T each. + expect(result.plan).toHaveLength(5); + expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([20, 20, 20, 20, 20]); + }); + + it('weight cap binds before items-fit when items are heavy', () => { + // 14 items of 10T on 70T wagons with a 100-item floor fit → 7 items/wagon. + const heavy = { + ...machineryBooking(), + cargoTotalWeightVgm: 14, + bulkTotalWeightTons: 140, + cargoType: { + id: 'ct-machinery', + cargoTypeName: 'Machinery', + itemsPerWagonMap: { 'wt-nw5': 100, 'wt-pw2': 100 }, + wagonTypes: [pw2, nw5], + }, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [heavy], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 70]); + }); + + it('PER_TON bulk (no bulkTotalWeightTons) still packs by weight', () => { + const loose = { + ...machineryBooking(), + cargoTotalWeightVgm: 100, + bulkTotalWeightTons: null, + } as unknown as Booking; + const result = planWagonsWithStock({ + bookings: [loose], + allowed, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([ + [pw2.id, 50], + [nw5.id, 50], + ]), + codesByTypeId: new Map([ + [pw2.id, pw2.code], + [nw5.id, nw5.code], + ]), + }, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(2); + expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0cf56ded5..0810b3369 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -2,6 +2,11 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { + bookingCargoTons, + bulkItemsFitFor, + bulkItemWagonsForAllowedTypes, +} from './train-capacity.util'; import { sortBookingsForScheduling, type BookingWagonShortage, @@ -64,6 +69,12 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Whole-item slots left on this wagon (break-bulk PER_ITEM cargo only — + * bounded by the cargo type's items-per-wagon fit and by tonnage). Undefined + * for weight-only (PER_TON) bulk and container wagons. + */ + freeItems?: number; /** * Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers * prefer a same-leg slot but may extend onto a different-leg one (span @@ -110,10 +121,19 @@ const shortageFor = ( booking.freightType === 'BULK' ? Math.max( 1, - Math.ceil( - Number(booking.cargoTotalWeightVgm ?? 0) / - Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), - ), + // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map + // respected); PER_TON falls through to tonnage over the largest + // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that + // column is the item count, not tons. + bulkItemWagonsForAllowedTypes( + booking, + booking.cargoType, + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ) || + Math.ceil( + bookingCargoTons(booking) / + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ), ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( @@ -347,18 +367,64 @@ export function planWagonsWithStock(params: { }; } const allowedIds = new Set(candidates.map((wt) => wt.id)); - let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0)); + // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the + // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves + // it either way. Items are indivisible, so a wagon takes whole items only, + // bounded by tonnage AND by the cargo type's items-per-wagon fit. + const quantity = Number(booking.cargoTotalWeightVgm ?? 0); + const perItem = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && quantity > 0; + let remainingWeight = roundTons(bookingCargoTons(booking)); + const perItemTons = perItem ? remainingWeight / quantity : 0; + let remainingItems = perItem ? quantity : 0; + + /** Whole items one wagon of this slot's type can still take. */ + const itemRoomOf = (open: OpenSlot): number => + Math.min( + open.freeItems ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0, + ); + /** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */ + const itemBudgetOf = (open: OpenSlot): number => { + const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); + const byTonnage = + perItemTons > 0 + ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + : 1; + return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); + }; let placedAnywhere = false; + // Per-item: prefer the type carrying the most whole items per wagon. + // openSlot's own capacity sort is stable, so this order breaks its ties. + const itemBudgetOfType = (wt: WagonType): number => + Math.min( + bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, + perItemTons > 0 + ? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) + : 1, + ); + const orderedCandidates = perItem + ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + : candidates; + // Top off wagons already carrying THIS cargo type before opening new ones. + // ponytail: per-item cargo only shares wagons that were opened per-item + // (freeItems tracked); mixing itemized and loose loads of one cargo type + // on one wagon is not modeled — open a new wagon instead. for (const open of openSlots) { - if (remainingWeight <= 0) break; + if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; - const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + if (perItem !== (open.freeItems !== undefined)) continue; + const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; + if (perItem && takeItems <= 0) continue; + const take = perItem + ? roundTons(takeItems * perItemTons) + : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); addAllocation( open.slot, booking.id, @@ -367,14 +433,39 @@ export function planWagonsWithStock(params: { AllocationLoadType.Bulk, ); open.freeCapacityTons = roundTons(open.freeCapacityTons - take); + if (perItem) { + open.freeItems = (open.freeItems ?? 0) - takeItems; + remainingItems -= takeItems; + } remainingWeight = roundTons(remainingWeight - take); placedAnywhere = true; } - while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); + while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { + // Per-item: openSlot's stock-depth tie-break would override the fit + // preference, so hand it exactly the best in-stock type (full candidate + // list only when none has stock, for the proper shortfall message). + const inStockBest = perItem + ? orderedCandidates.find((wt) => availableFor(wt.id, leg) > 0) + : undefined; + const openedSlot = openSlot( + inStockBest ? [inStockBest] : orderedCandidates, + 'BULK', + cargoTypeId, + leg, + ); if ('message' in openedSlot) return openedSlot; - const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + let take: number; + if (perItem) { + // An item heavier than a whole wagon still charges 1 wagon per item + // (creation-time validation owns rejecting that case). + const takeItems = Math.max(1, Math.min(itemBudgetOf(openedSlot), remainingItems)); + take = roundTons(Math.min(takeItems * perItemTons, remainingWeight)); + openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems; + remainingItems -= takeItems; + } else { + take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + } addAllocation( openedSlot.slot, booking.id, @@ -399,6 +490,7 @@ export function planWagonsWithStock(params: { teuPerEdge: [...open.teuPerEdge], covered: { ...open.covered }, freeCapacityTons: open.freeCapacityTons, + freeItems: open.freeItems, assignedWeightTons: open.slot.assignedWeightTons, allocationCount: open.slot.allocations.length, allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), @@ -420,6 +512,7 @@ export function planWagonsWithStock(params: { open.teuPerEdge = [...snap.teuPerEdge]; open.covered = { ...snap.covered }; open.freeCapacityTons = snap.freeCapacityTons; + open.freeItems = snap.freeItems; open.slot.assignedWeightTons = snap.assignedWeightTons; open.slot.allocations.length = snap.allocationCount; snap.allocationWeights.forEach((weight, allocationIndex) => { diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index b0a01f625..ecd903128 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -2,6 +2,7 @@ import { Package } from "lucide-react"; import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { SectionCard } from "./SectionCard"; import { MetricTile } from "./MetricTile"; @@ -13,6 +14,7 @@ export interface BookingCargoCardProps { /** Cargo specs + container manifest table. */ export function BookingCargoCard({ booking }: BookingCargoCardProps) { const containers = booking.bookingContainers ?? []; + const { tons, items } = cargoTonsAndItems(booking); return ( @@ -21,7 +23,8 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { label="Cargo type" value={booking.cargoType?.label ?? booking.freightType} /> - + + {items != null && } { + const { tons, items } = cargoTonsAndItems(booking); + return items != null ? `${tons} tons (${items} items)` : `${tons} tons`; + })(), + }, { icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) }, ]; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx index 3ee5d72a0..ed9802150 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRequestHero.tsx @@ -22,6 +22,7 @@ import { import type { LucideIcon } from "lucide-react"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; @@ -52,7 +53,7 @@ export function BookingRequestHero({ (sum, c) => sum + Number(c.quantity ?? 0), 0, ); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); return ( + setContainerLines((prev) => + prev.map((l, i) => { + if (i !== lineIdx) return l; + const units = l.units.filter((_, j) => j !== unitIdx); + return withDerivedCounts({ ...l, quantity: String(units.length), units }); + }), + ); + // Same client-side validation as the customer portal shipment form // (new-shipment-form/schema.ts): ISO container numbers unique within the // shipment, positive VGM per unit, hazardous/reefer counts bounded by the @@ -1362,6 +1374,8 @@ export default function GlCreateBookingForm() { } onChange={(e) => { patchLine(lineIdx, { quantity: e.currentTarget.value }); + }} + onBlur={(e) => { syncUnits(lineIdx, Number(e.currentTarget.value || 0)); }} radius={10} @@ -1495,6 +1509,14 @@ export default function GlCreateBookingForm() { /> ))} + removeUnit(lineIdx, unitIdx)} + > + + ))} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 831e1bc1b..2ca73f10a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -182,18 +182,18 @@ const FreightDashboardHeader = ({ )} + } + onClick={() => navigate("/dashboard/profile#signature")} + > + Signature & Stamp + } onClick={() => navigate("/dashboard/profile")} > Profile - } - onClick={() => navigate("/dashboard/profile#signature")} - > - My signature - } diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx index ca895b70b..ca9effd27 100644 --- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -79,7 +79,7 @@ export function MySignatureCard() { - My signature + Signature & Stamp This signature can be reused to sign booking contracts. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx index 12ef228df..69dc7f2b8 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AllocateBookingWizard.tsx @@ -38,6 +38,7 @@ import { formatRouteLabel } from "@/services/routes.service"; import { useToast } from "@/hooks/use-toast"; import { trainSchedulingService } from "@/services/trainScheduling.service"; import type { BookingDetail } from "@/types/booking"; +import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { ContainerPlacement, FreightType, @@ -416,7 +417,7 @@ export function AllocateBookingWizard({ const amount = Number(booking.totalAmount); const containers = booking.bookingContainers ?? []; const containerCount = containers.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); - const weight = Number(booking.cargoTotalWeightVgm ?? 0); + const { tons: weight, items: itemCount } = cargoTonsAndItems(booking); const holdCountdown = formatCountdown(booking.holdExpiresAt); const containerComplete = @@ -945,7 +946,13 @@ export function AllocateBookingWizard({ })}`} hint={booking.paymentStatus} /> - + —; return ( <> - {fmt(need.wagons)} + + {wagonBreakdown?.length ? ( + // Which wagon TYPES this train gives up, not just how many wagons — + // a break-bulk booking's count depends on each type's capacity and + // its configured items-per-wagon fit, so it differs per train. + + {wagonBreakdown.map((entry) => ( + + {entry.wagons} × {entry.code} + + ))} + + ) : ( + fmt(need.wagons) + )} + {fmt(need.weightTons)} t {fmt(need.lengthMeters)} m @@ -285,7 +301,7 @@ export function IntercityRideAlongPanel({ - + {row.fits ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 6ea4c40e9..f176fc05d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -12,6 +12,7 @@ import toast from "react-hot-toast"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { bookingSurface } from "@/components/bookings/booking-ui.styles"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { invalidateBookingDetail } from "@/utils/queryInvalidation"; @@ -40,6 +41,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // Company stamp: prefilled from the profile, or uploaded here when none is + // saved yet. + const [stampData, setStampData] = useState(null); // When the user has a saved signature we offer it for approval first; they // can switch to drawing a fresh one. const [drawNew, setDrawNew] = useState(false); @@ -55,6 +59,7 @@ export default function BookingContractPage() { const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + const savedStampImage = savedSignature?.stampImageUrl ?? null; // Show the approval view only while a saved signature exists and the user // hasn't opted to draw a new one. const usingSaved = Boolean(savedSignatureImage) && !drawNew; @@ -98,6 +103,8 @@ export default function BookingContractPage() { // approve it; otherwise start with an empty pad. setSignerName(savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + // Prefill with the reusable stamp saved on the profile; still replaceable. + setStampData(savedStampImage); setDrawNew(false); setSignOpen(true); }; @@ -106,10 +113,12 @@ export default function BookingContractPage() { if (!canSign || !signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + // The API rejects a STAFF signature without a stamp. + if (!image || !stampData) return; signMutation.mutate({ role: "STAFF", signatureImageBase64: image, + stampImageBase64: stampData, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -234,6 +243,15 @@ export default function BookingContractPage() { ) : ( )} + + ), + } + : { + label: "Assigned train", + value: "Not yet assigned", + muted: true, + }; const statusRow: Row = { label: "Status", @@ -115,6 +148,17 @@ export function ScheduleCard({ ))} + + {booking.trainScheduleId && ( + setTrackingOpen(false)} + bookingId={booking.id} + bookingReference={booking.reference} + originLabel={yardLabel(booking.originYard)} + destinationLabel={yardLabel(booking.destinationYard)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx index 8051e9986..a2a48c7dc 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentDetailsCard.tsx @@ -69,10 +69,7 @@ export function ShipmentDetailsCard({ booking }: { booking: BookingDetail }) { ], ["Scheduled date", fmtDate(booking.scheduledDate)], ], - [ - ["Shipping line", shippingLineLabel(booking)], - ["Assigned train", booking.trainId ?? "Not yet assigned"], - ], + [["Shipping line", shippingLineLabel(booking)]], ]; const badges: string[] = []; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 924ee16a4..22b347e85 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -83,6 +83,10 @@ export function totalVgmTons(b: BookingDetail): number { ); if (sum > 0) return sum; } + // Break-bulk (PER_ITEM): cargoTotalWeightVgm holds the ITEM COUNT — the + // real tonnage lives in bulkTotalWeightTons. + const bulkTons = Number(b.bulkTotalWeightTons || 0); + if (bulkTons > 0) return bulkTons; return Number(b.cargoTotalWeightVgm || 0); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx index 3f20c79e4..4a9ef757c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx @@ -77,6 +77,7 @@ const STATUS_FILTERS = [ key: "all", label: "All bookings", statuses: undefined as string | undefined, + assignedToSchedule: undefined as "true" | "false" | undefined, }, { key: "active", @@ -89,9 +90,16 @@ const STATUS_FILTERS = [ key: "payment", label: "Awaiting payment", statuses: - "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED", + "SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS", }, { key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" }, + { + key: "allocated", + label: "Allocated to a train", + statuses: undefined as string | undefined, + assignedToSchedule: "true" as const, + }, + { key: "expired", label: "Expired", statuses: "EXPIRED" }, { key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" }, { key: "closed", @@ -348,7 +356,10 @@ export default function BookingsListPage() { const [trackingBooking, setTrackingBooking] = useState(null); - const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; + const activeFilter = STATUS_FILTERS.find((t) => t.key === statusFilter); + const statuses = activeFilter?.statuses; + const assignedToSchedule = + "assignedToSchedule" in activeFilter! ? activeFilter.assignedToSchedule : undefined; const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; const resetPage = () => @@ -372,6 +383,7 @@ export default function BookingsListPage() { const filter: BookingListFilter = useMemo( () => ({ statuses, + assignedToSchedule, bookingType: typeFilter ?? undefined, freightType: freightFilter ?? undefined, createdFrom: createdFrom || undefined, @@ -386,6 +398,7 @@ export default function BookingsListPage() { }), [ statuses, + assignedToSchedule, typeFilter, freightFilter, createdFrom, @@ -423,6 +436,8 @@ export default function BookingsListPage() { draft: draftCount, done: doneCount, transit: undefined, + allocated: undefined, + expired: undefined, closed: undefined, }; @@ -557,7 +572,12 @@ export default function BookingsListPage() { size: 130, meta: hMeta, header: () => , - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, { id: "scheduling", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index 24d9d421e..ec5abad1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -144,17 +144,31 @@ export function paymentStatusLabel(status?: string | null): string { return PAYMENT_LABELS[status] ?? titleCaseStatus(status); } -/** Payment status pill. */ -export function PaymentBadge({ status }: { status?: string | null }) { - if (!status) return ; +/** + * Payment status pill. Once the booking's own lifecycle status has moved past + * payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled + * fact: show "Paid" even if a stale/lagging `paymentStatus` value says + * otherwise, rather than surface a contradictory "Paid booking, pending + * payment" row. + */ +export function PaymentBadge({ + status, + bookingStatus, +}: { + status?: string | null; + bookingStatus?: string | null; +}) { + const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false; + const effective = settled ? "PAID" : status; + if (!effective) return ; return ( - {PAYMENT_LABELS[status] ?? titleCaseStatus(status)} + {PAYMENT_LABELS[effective] ?? titleCaseStatus(effective)} ); } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 79b904f47..6ceb1fb69 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -1316,7 +1316,10 @@ export default function ContractDetailPage() { )} - + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 99eef8bdf..50f2828f6 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -11,6 +11,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "react-router-dom"; import { + ActionIcon, Alert, Box, Button, @@ -515,7 +516,9 @@ function NewShipmentBookingForm({ routes={routes} completeBookingId={completeBookingId ?? null} /> - + {/* Notes are captured when the booking is initiated — completing + a bare booking does not re-ask for them. */} + {!completeBookingId && } @@ -989,8 +992,9 @@ function ScheduleStep({ ?.cargoTypeCode ?? undefined, totalWeightTons: tons, }; - // itemCount is referenced so the query refreshes when a PER_ITEM cargo - // amount changes (weight is the sizing input the backend uses). + // Tonnage is the sizing input the day-feasibility endpoint takes, and + // PER_ITEM cargo now captures it too — itemCount stays in the deps so the + // query still refreshes when only the item count changes. }, [ route, isContainer, @@ -1159,6 +1163,9 @@ function CargoStep({ contract: Freight.IContract; }) { const isContainer = contract.freightType === "CONTAINER"; + // Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and + // the total tonnage (which sizes the wagons); PER_TON needs tonnage only. + const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM"; // Sizes enabled by the contract scope. const sizes = useMemo( () => @@ -1190,8 +1197,7 @@ function CargoStep({ // still needs its container number. useEffect(() => { if (!remainderMode || isContainer) return; - const field = - bulkUnitOfMeasure(contract) === "PER_ITEM" ? "itemCount" : "cargoWeightTons"; + const field = isPerItem ? "itemCount" : "cargoWeightTons"; if (!form.getValues(field)) { form.setValue(field, String(remainderLines[0].remaining), { shouldValidate: false, @@ -1475,6 +1481,27 @@ function CargoStep({ {remainderNotice} + {isPerItem && ( + ( + + )} + /> + )} )} /> - ( - - )} - /> {contract.isHazardous && ( { + const current = form.getValues(`containers.${index}.units`) ?? []; + const next = current.filter((_, j) => j !== unitIdx); + form.setValue(`containers.${index}.units`, next, { shouldValidate: false }); + form.setValue(`containers.${index}.quantity`, String(next.length), { + shouldValidate: true, + }); + syncHandlingCounts(next); + }; + /** * Line totals are a roll-up of the per-container switches — the count is * however many containers ticked each service. Kept in form state so the @@ -1781,8 +1807,10 @@ function ContainerLineEditor({ styles={fieldStyles} onChange={(e) => { field.onChange(e.currentTarget.value); - const qty = Number(e.currentTarget.value || 0); - syncUnits(qty); + }} + onBlur={(e) => { + field.onBlur(); + syncUnits(Number(e.currentTarget.value || 0)); }} /> )} @@ -1906,6 +1934,14 @@ function ContainerLineEditor({ )} /> ))} + removeUnit(u)} + > + + ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 5aca3fc81..93f5c23c5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -1,4 +1,4 @@ -import { Controller, type UseFormReturn } from "react-hook-form"; +import { type UseFormReturn } from "react-hook-form"; import { Badge, Box, @@ -7,13 +7,11 @@ import { Paper, Stack, Text, - Textarea, } from "@mantine/core"; import { CheckCircle2, Circle, ClipboardCheck, - Coins, FileText, MapPin, Package, @@ -220,13 +218,13 @@ export function Step8Review({ // not of the stored form flag — a stale draft flag must not misreport it. // Without bundling, the customer may still name their own clearing agent. const ownAgent = values.customsClearingAgent?.trim(); - const customsValue = isIntercity - ? "Not applicable — domestic transport" + const customsTag: { label: string; color: string } = isIntercity + ? { label: "Not applicable · domestic", color: "gray" } : serviceType?.includesCustoms || values.customsClearingEnabled - ? "Included — Global Logistics" + ? { label: "EDR handles it · Global Logistics", color: "edr-green" } : ownAgent - ? `Own agent — ${ownAgent}` - : "Not requested"; + ? { label: `Own agent · ${ownAgent}`, color: "blue" } + : { label: "Not requested", color: "gray" }; // Mirror the step-2 gating: imports never truck the first mile, exports never // truck the last mile, and a service that doesn't bundle a mile can't have it. @@ -351,27 +349,26 @@ export function Step8Review({ label="Service" value={serviceType?.serviceName ?? "—"} /> - } - label="Quotation currency" - value={ - <> - USD - - You choose the billing currency on each shipment. - - - } - /> } label="Route" - value={`${originYardName} → ${destinationYardName}`} - /> - } - label="Trade direction" - value={directionLabel} + value={ + + + {originYardName} → {destinationYardName} + + } + style={{ flexShrink: 0 }} + > + {directionLabel} + + + } /> } @@ -420,7 +417,16 @@ export function Step8Review({ } label="Customs clearing" - value={customsValue} + value={ + + {customsTag.label} + + } /> {/* Step-3 toggles appear only when the customer selected them — an off toggle is left off the summary entirely. */} @@ -478,20 +484,6 @@ export function Step8Review({ {documentsEditor} )} - - ( -