From 08faf56c4f60f46919a7699ce8041879d0876692 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 06:43:12 +0000 Subject: [PATCH 1/9] fix: payment autopay on the payment init added for testing --- .../src/modules/billing/billing.service.ts | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) 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 8c9461018..3b14d6f4c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) {} + ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -432,7 +432,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); @@ -991,26 +991,26 @@ export class BillingService { returnUrl: opts.returnUrl, failureUrl: opts.failureUrl, }); -// + // // Link the intent to the invoice BEFORE any settlement can correlate against it. await this.dataSource .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( From 312014b6780cf611eeee48c5763874db0dfe15de Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:13:24 +0000 Subject: [PATCH 2/9] fix first/last mile --- .../src/pages/operations/FirstMilePage.tsx | 60 ++++++++++++++++++- .../src/pages/operations/LastMilePage.tsx | 60 ++++++++++++++++++- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index a8c55ecf4..69812182f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => { ]; return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; }; +/** + * The mile bills as distance × pricePerKm in the vehicle's own currency, so a + * vehicle missing either field cannot produce an invoice line. Returns the + * human-readable gap, or null when the vehicle is billable. + */ +const pricingGap = ( + v?: { pricePerKm?: number | string | null; currency?: string | null } | null, +): string | null => { + if (!v) return null; + const missing: string[] = []; + if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM"); + if (!String(v.currency ?? "").trim()) missing.push("Currency"); + return missing.length ? missing.join(" and ") : null; +}; const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—"; const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—"; const cargoDesc = (r: FirstMileRecord) => { @@ -681,6 +695,22 @@ const FirstMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment + // below rather than silently billing 0 once distances are entered. + const pricingGapById = useMemo(() => { + const map = new Map(); + const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => { + if (v?.id) map.set(v.id, pricingGap(v)); + }; + for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v); + for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle); + add(activeRecord?.vehicle); + return map; + }, [vehiclesData, activeRecord]); + + const vehicleLabelFor = (id: string) => + assignVehicleOptions.find((o) => o.value === id)?.label ?? id; + // Full booking (with container units) for the assign modal's container dropdown. // Fetched on open so container numbers show regardless of what the list embeds. const { data: assignBooking } = useQuery({ @@ -932,6 +962,21 @@ const FirstMilePage = () => { if (!targetIds.length) return; + // Backstop for rows the Select guard never saw (pre-filled reassignments). + const unpriced = vehicles + .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) + .filter((v): v is { label: string; gap: string } => Boolean(v.gap)); + if (unpriced.length) { + toast({ + title: "Vehicle is not priced", + description: `${unpriced + .map((v) => `${v.label} (${v.gap} not set)`) + .join("; ")} — set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + // Empty set = unassign all (setVehicles releases the removed vehicles). Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { @@ -1365,9 +1410,18 @@ const FirstMilePage = () => { (o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), )} value={row.vehicleId} - onChange={(v) => - setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) - } + onChange={(v) => { + const gap = v ? pricingGapById.get(v) : null; + if (v && gap) { + toast({ + title: "Vehicle is not priced", + description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))); + }} searchable clearable disabled={assignVehicleOptions.length === 0} 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 e5de54880..cd5d22c6b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -251,6 +251,20 @@ const billingIssues = (r: LastMileRecord) => { ]; return { zeroPrice, mixedCurrency: currencies.length > 1, currencies }; }; +/** + * The mile bills as distance × pricePerKm in the vehicle's own currency, so a + * vehicle missing either field cannot produce an invoice line. Returns the + * human-readable gap, or null when the vehicle is billable. + */ +const pricingGap = ( + v?: { pricePerKm?: number | string | null; currency?: string | null } | null, +): string | null => { + if (!v) return null; + const missing: string[] = []; + if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM"); + if (!String(v.currency ?? "").trim()) missing.push("Currency"); + return missing.length ? missing.join(" and ") : null; +}; const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—"; const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—"; const cargoDesc = (r: LastMileRecord) => { @@ -870,6 +884,22 @@ const LastMilePage = () => { return opts; }, [vehicleOptions, activeRecord]); + // Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment + // below rather than silently billing 0 once distances are entered. + const pricingGapById = useMemo(() => { + const map = new Map(); + const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => { + if (v?.id) map.set(v.id, pricingGap(v)); + }; + for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v); + for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle); + add(activeRecord?.vehicle); + return map; + }, [vehiclesData, activeRecord]); + + const vehicleLabelFor = (id: string) => + assignVehicleOptions.find((o) => o.value === id)?.label ?? id; + // Full booking (with container units) for the assign modal's container dropdown. // Fetched on open so container numbers show regardless of what the list embeds. const { data: assignBooking } = useQuery({ @@ -1018,6 +1048,21 @@ const LastMilePage = () => { if (!targetIds.length) return; + // Backstop for rows the Select guard never saw (pre-filled reassignments). + const unpriced = vehicles + .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) + .filter((v): v is { label: string; gap: string } => Boolean(v.gap)); + if (unpriced.length) { + toast({ + title: "Vehicle is not priced", + description: `${unpriced + .map((v) => `${v.label} (${v.gap} not set)`) + .join("; ")} — set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + // Empty set = unassign all (setVehicles releases the removed vehicles). Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles }))) .then(() => { @@ -1738,9 +1783,18 @@ const LastMilePage = () => { (o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value), )} value={row.vehicleId} - onChange={(v) => - setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))) - } + onChange={(v) => { + const gap = v ? pricingGapById.get(v) : null; + if (v && gap) { + toast({ + title: "Vehicle is not priced", + description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`, + variant: "destructive", + }); + return; + } + setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x))); + }} searchable clearable disabled={assignVehicleOptions.length === 0} From 5f6d8f9294eea211fc52b036d8178daf8d826fef Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 07:17:22 +0000 Subject: [PATCH 3/9] fix: cache invalidation --- .../pages/contracts/ContractClearanceDetailPage.tsx | 11 +++++++++-- .../src/pages/contracts/GlClearanceDetailPage.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 76666c320..619ce59ad 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -110,6 +110,13 @@ export default function ContractClearanceDetailPage() { const { data: bookingMilestones, refetch: refetchBookingMilestones } = useBookingMilestones(linkedBookingId); + // react-query's imperative refetch() ignores `enabled`, so calling it while + // linkedBookingId is still undefined (pre-booking clearance) would fire + // GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it. + const refetchBookingMilestonesIfLinked = () => { + if (linkedBookingId) void refetchBookingMilestones(); + }; + if (isLoading) { return ( @@ -261,7 +268,7 @@ export default function ContractClearanceDetailPage() { onChanged={() => { void refetch(); void refetchContract(); - void refetchBookingMilestones(); + refetchBookingMilestonesIfLinked(); }} /> @@ -279,7 +286,7 @@ export default function ContractClearanceDetailPage() { roleMode="ET" onChanged={() => { void refetch(); - void refetchBookingMilestones(); + refetchBookingMilestonesIfLinked(); }} onViewFile={view} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index 3444e85f1..daff48939 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -105,6 +105,13 @@ export default function GlClearanceDetailPage() { const { data: bookingMilestones, refetch: refetchBookingMilestones } = useBookingMilestones(linkedBookingId); + // react-query's imperative refetch() ignores `enabled`, so calling it while + // linkedBookingId is still undefined (pre-booking clearance) would fire + // GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it. + const refetchBookingMilestonesIfLinked = () => { + if (linkedBookingId) void refetchBookingMilestones(); + }; + if (isLoading) { return ( @@ -275,7 +282,7 @@ export default function GlClearanceDetailPage() { onUploadRoRequest={() => setUploadKind("ro")} onChanged={() => { void refetch(); - void refetchBookingMilestones(); + refetchBookingMilestonesIfLinked(); }} onViewFile={view} onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)} From a4b330941215f32e24732bce9e1a07bd7f75b9c1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:43:54 +0000 Subject: [PATCH 4/9] add seed --- ...0000000000-SeedEdrWagonFleetErNumbering.ts | 104 ++++++++++++++++++ .../src/scripts/seed-edr-wagons.ts | 34 +++--- 2 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts new file mode 100644 index 000000000..fdfcf8be5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Re-seed the EDR wagon fleet onto the official ER numbering. + * + * Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons + * on a `-NNNN` scheme and wrote the status as 'Available' — mixed case + * that never matches WagonStatus.Available ('AVAILABLE'), so status filters + * silently returned nothing. This seed uses the enum value. + * + * Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon + * specs (capacity/length/tare) stay owned by wagon_types and are not touched — + * the types already exist and only the wagon↔type link is (re)established here. + */ +type FleetRow = { + code: string; + start: number; + end: number; + count: number; +}; + +/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */ +const FLEET: FleetRow[] = [ + { code: 'PW2', start: 1, end: 220, count: 220 }, + { code: 'CW4', start: 221, end: 330, count: 110 }, + { code: 'CW3', start: 331, end: 350, count: 20 }, + { code: 'KW2', start: 351, end: 370, count: 20 }, + { code: 'KW3', start: 371, end: 390, count: 20 }, + { code: 'NW5', start: 391, end: 940, count: 550 }, + { code: 'BW1', start: 941, end: 950, count: 10 }, + { code: 'GW2', start: 951, end: 1060, count: 110 }, + { code: 'NW6', start: 1061, end: 1080, count: 20 }, + { code: 'NW7', start: 1081, end: 1100, count: 20 }, +]; + +const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`; + +export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface { + name = 'SeedEdrWagonFleetErNumbering2260000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Full replacement: the ER range is the fleet of record, so any wagon + // outside it is stale seed data. Safe to hard-delete — containers and + // train_set_wagons null their link, wagon_movements cascade. + await queryRunner.query(`DELETE FROM freight.wagons;`); + + for (const row of FLEET) { + if (row.end - row.start + 1 !== row.count) { + throw new Error(`wagon_range_mismatch:${row.code}`); + } + + const [typeRecord] = await queryRunner.query( + `SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`, + [row.code], + ); + + if (!typeRecord?.id) { + throw new Error(`wagon_type_missing:${row.code}`); + } + + // generate_series builds the range server-side — one round trip per type + // instead of 1100 individual INSERTs. + await queryRunner.query( + ` + INSERT INTO freight.wagons ( + wagon_number, + wagon_type_id, + status, + current_yard_id, + train_id, + sequence_number, + notes, + train_set_wagon_id, + current_train_schedule_id + ) + SELECT + 'ER' || LPAD(seq::text, 4, '0'), + $1::uuid, + 'AVAILABLE', + NULL, + NULL, + NULL, + NULL, + NULL, + NULL + FROM generate_series($2::int, $3::int) AS seq + ON CONFLICT (wagon_number) DO UPDATE SET + wagon_type_id = EXCLUDED.wagon_type_id, + status = EXCLUDED.status, + current_yard_id = EXCLUDED.current_yard_id, + updated_at = now(); + `, + [typeRecord.id, row.start, row.end], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`, + [wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)], + ); + } +} diff --git a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts index b6a6484f8..716532165 100644 --- a/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts +++ b/apps/edr-freight-api/src/scripts/seed-edr-wagons.ts @@ -1,5 +1,5 @@ import { AppDataSource } from '../data-source'; -import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet'; +import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering'; async function seedEdRWagons() { await AppDataSource.initialize(); @@ -10,28 +10,32 @@ async function seedEdRWagons() { await queryRunner.connect(); await queryRunner.startTransaction(); - await new SeedEdRWagonFleet1750400000000().up(queryRunner); + await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner); - const [summary] = await queryRunner.query(` + const summary = await queryRunner.query(` SELECT - COUNT(*)::int AS total, - COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2, - COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4, - COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3, - COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2, - COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3, - COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5, - COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready, - COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready, - COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready + wt.code, + wt.name, + COUNT(*)::int AS wagons, + MIN(w.wagon_number) AS first_wagon, + MAX(w.wagon_number) AS last_wagon, + COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available, + COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard FROM freight.wagons w JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id - WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940'; + WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100' + GROUP BY wt.code, wt.name + ORDER BY MIN(w.wagon_number); + `); + + const [totals] = await queryRunner.query(` + SELECT COUNT(*)::int AS total FROM freight.wagons; `); await queryRunner.commitTransaction(); - console.log('Seeded EDR wagon fleet:', summary); + console.table(summary); + console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`); } catch (error) { await queryRunner.rollbackTransaction(); throw error; From 4a04aa7e974f7d811a05765d520e9704cca9423b Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 08:13:22 +0000 Subject: [PATCH 5/9] fic --- ...0000000000-SeedEdrWagonFleetErNumbering.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts index fdfcf8be5..717a48217 100644 --- a/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts +++ b/apps/edr-freight-api/src/migrations/2260000000000-SeedEdrWagonFleetErNumbering.ts @@ -44,6 +44,14 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter // train_set_wagons null their link, wagon_movements cascade. await queryRunner.query(`DELETE FROM freight.wagons;`); + // Wagon.wagonNumber declares `unique: true`, but some environments never got + // the constraint. Repair it here — the table is empty at this point, so the + // index build cannot fail on pre-existing duplicates. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key + ON freight.wagons (wagon_number); + `); + for (const row of FLEET) { if (row.end - row.start + 1 !== row.count) { throw new Error(`wagon_range_mismatch:${row.code}`); @@ -59,7 +67,9 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter } // generate_series builds the range server-side — one round trip per type - // instead of 1100 individual INSERTs. + // instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon + // was deleted above, so a plain INSERT cannot collide, and the clause would + // otherwise hard-require a unique index this table lacks on some envs. await queryRunner.query( ` INSERT INTO freight.wagons ( @@ -83,12 +93,7 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter NULL, NULL, NULL - FROM generate_series($2::int, $3::int) AS seq - ON CONFLICT (wagon_number) DO UPDATE SET - wagon_type_id = EXCLUDED.wagon_type_id, - status = EXCLUDED.status, - current_yard_id = EXCLUDED.current_yard_id, - updated_at = now(); + FROM generate_series($2::int, $3::int) AS seq; `, [typeRecord.id, row.start, row.end], ); From 6dde17fa4dc1cf53a7d5d1c6086cea0b91a4ccc0 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 08:38:49 +0000 Subject: [PATCH 6/9] fix: premature payable invoice --- .../modules/billing/billing.service.spec.ts | 93 +++++++++++++++++++ .../src/modules/billing/billing.service.ts | 59 +++++++++--- .../bookings/booking-transition.service.ts | 23 +++-- .../booking-batch.service.spec.ts | 8 +- .../train-scheduling/booking-batch.service.ts | 10 +- 5 files changed, 167 insertions(+), 26 deletions(-) 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..4d647e3ef 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; } /** 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/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, From 318af79962ff546219dd2c600ffd64e9ef7dde99 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 16 Jul 2026 08:52:29 +0000 Subject: [PATCH 7/9] feat: setup posthog --- apps/edr-freight-web/backoffice/.env.example | 5 + apps/edr-freight-web/backoffice/package.json | 2 + .../backoffice/src/auth/AuthProvider.tsx | 4 + .../backoffice/src/auth/http.ts | 9 + .../src/components/ErrorBoundary.tsx | 3 + .../backoffice/src/lib/posthog.ts | 140 ++++++++++ apps/edr-freight-web/backoffice/src/main.tsx | 43 +-- .../components/common/ErrorBoundary.tsx | 5 +- .../backoffice/src/vite-env.d.ts | 5 + apps/edr-freight-web/portal/package.json | 2 + apps/edr-freight-web/portal/src/App.tsx | 4 + .../components/errors/AppErrorFallback.tsx | 31 +++ .../edr-freight-web/portal/src/lib/posthog.ts | 151 +++++++++++ apps/edr-freight-web/portal/src/main.tsx | 28 +- apps/edr-freight-web/portal/src/utils/api.ts | 9 + apps/edr-freight-web/portal/src/vite-env.d.ts | 6 + docker-compose.yaml | 4 + infrastructure/docker/Dockerfile.web | 4 + pnpm-lock.yaml | 248 ++++++------------ 19 files changed, 513 insertions(+), 190 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/lib/posthog.ts create mode 100644 apps/edr-freight-web/portal/src/components/errors/AppErrorFallback.tsx create mode 100644 apps/edr-freight-web/portal/src/lib/posthog.ts 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, +): void { + if (!enabled) return; + posthog.captureException(error, properties); +} + +/** + * Report a failed API call. + * + * Called from the axios interceptor rather than from `emitApiError`, which + * early-returns on suppressed paths (warehouse / first-mile / onboarding / + * auth) — hooking there would drop errors on exactly those pages. + * + * 5xx and network failures are real defects and go to Error tracking. 4xx is + * usually the server correctly rejecting input, so it is recorded as a plain + * event to keep the issue list signal-heavy. + */ +export function captureApiError(error: unknown): void { + if (!enabled) return; + + const err = error as { + message?: string; + config?: { method?: string; url?: string }; + response?: { status?: number }; + }; + + const status = err.response?.status; + const properties = { + api_status: status ?? null, + api_method: err.config?.method?.toUpperCase() ?? null, + api_path: err.config?.url ?? null, + }; + + if (status && status < 500) { + posthog.capture("api_error", properties); + return; + } + + posthog.captureException(error, properties); +} + +/** + * Identify the current user to PostHog so replays and exceptions are + * attributable. + * + * Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber, + * name and username; these are railway staff, and debugging a replay never + * requires knowing how to phone the person in it. + */ +export function useIdentify(user: AuthUser | null): void { + const employee = user?.employee?.[0]; + + useEffect(() => { + if (!enabled) return; + + if (!user?.id) { + posthog.reset(); + return; + } + + posthog.identify(user.id, { + roles: user.roles?.map((role) => role.key ?? role.id), + status: user.status, + is_super_admin: user.isSuperAdmin, + organization_id: employee?.organizationId, + unit_id: employee?.unitId, + }); + }, [ + user?.id, + user?.roles, + user?.status, + user?.isSuperAdmin, + employee?.organizationId, + employee?.unitId, + ]); +} diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index 63fbcb274..eacd05459 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -2,6 +2,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; +import posthog from "posthog-js"; +import { PostHogProvider } from "@posthog/react"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "@edr/ui-common/styles.css"; @@ -19,6 +21,7 @@ import { ApiErrorModal } from "./components/errors/ApiErrorModal"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { AuthProvider } from "./auth/AuthProvider"; import { queryClient } from "./lib/queryClient"; +import { initPostHog } from "./lib/posthog"; import { freightMantineTheme } from "./theme/freight-brand"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -43,6 +46,10 @@ const applyStoredTheme = () => { applyStoredTheme(); +// Must run before render so replay and exception capture cover startup errors. +// No-ops when VITE_POSTHOG_KEY is unset. +initPostHog(); + const rootElement = document.getElementById("root"); if (!rootElement) { @@ -50,23 +57,25 @@ if (!rootElement) { } createRoot(rootElement).render( - - - - - - - - - {/* Global API error modal — shows the server's actual error - message (suppressed on warehouse / mile / onboarding pages). */} - - - - - - - + + + + + + + + + + {/* Global API error modal — shows the server's actual error + message (suppressed on warehouse / mile / onboarding pages). */} + + + + + + + + ); // run \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/ErrorBoundary.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/ErrorBoundary.tsx index 9a0c8cc2a..4a188ed6e 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/ErrorBoundary.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/ErrorBoundary.tsx @@ -1,5 +1,7 @@ import React, { Component, ErrorInfo, ReactNode } from "react"; +import { captureException } from "@/lib/posthog"; + interface ErrorBoundaryProps { children: ReactNode; } @@ -20,7 +22,8 @@ export class ErrorBoundary extends Component< } componentDidCatch(error: Error, info: ErrorInfo) { - // Log to console in development; replace with a reporting service (e.g. Sentry) in production + captureException(error, { componentStack: info.componentStack }); + if (import.meta.env.DEV) { console.error("ErrorBoundary caught:", error, info.componentStack); } diff --git a/apps/edr-freight-web/backoffice/src/vite-env.d.ts b/apps/edr-freight-web/backoffice/src/vite-env.d.ts index f24da9f58..53d196cbd 100644 --- a/apps/edr-freight-web/backoffice/src/vite-env.d.ts +++ b/apps/edr-freight-web/backoffice/src/vite-env.d.ts @@ -17,6 +17,11 @@ interface Window { interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_BASE_API_URL: string; + readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string; + /** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */ + readonly VITE_POSTHOG_KEY?: string; + /** Self-hosted PostHog instance URL. */ + readonly VITE_POSTHOG_HOST?: string; } interface ImportMeta { diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 2cfd488d6..1e7b7155d 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -18,6 +18,7 @@ "@mantine/core": "^9.3.0", "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", + "@posthog/react": "^1.10.3", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", "@vis.gl/react-google-maps": "^1.8.3", @@ -26,6 +27,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "lucide-react": "^1.14.0", + "posthog-js": "^1.400.1", "radix-ui": "^1.4.3", "react": "19.2.6", "react-dom": "19.2.6", diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 659831616..7c5b5e5b5 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -25,6 +25,7 @@ import OnboardingResumeBanner, { import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import { ApiErrorModal } from "./components/errors/ApiErrorModal"; import useAuth from "./hooks/useAuth"; +import { useIdentify } from "./lib/posthog"; import { startTokenRefreshScheduler, stopTokenRefreshScheduler, @@ -221,6 +222,9 @@ const App = () => { const { user, company, companyType, createProfile, isAuthenticated } = useAuth(); + // Attribute replays and exceptions to the signed-in user (id/org only). + useIdentify(user, company); + // Keep the server session alive while a user is logged in. Runs after // login, signup, and page-reload bootstrap alike. useEffect(() => { diff --git a/apps/edr-freight-web/portal/src/components/errors/AppErrorFallback.tsx b/apps/edr-freight-web/portal/src/components/errors/AppErrorFallback.tsx new file mode 100644 index 000000000..7c6046113 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/errors/AppErrorFallback.tsx @@ -0,0 +1,31 @@ +/** + * Shown when a render error escapes to the app root. + * + * Before this existed, a render exception unmounted the tree and left the + * customer staring at a blank white page with no way forward. The matching + * `$exception` is reported by the surrounding PostHogErrorBoundary. + */ +export function AppErrorFallback() { + return ( +
+
+

+ Something went wrong +

+

+ This page failed to load. The problem has been reported. Try reloading + — if it keeps happening, please contact support. +

+ +
+
+ ); +} + +export default AppErrorFallback; diff --git a/apps/edr-freight-web/portal/src/lib/posthog.ts b/apps/edr-freight-web/portal/src/lib/posthog.ts new file mode 100644 index 000000000..59599fbb6 --- /dev/null +++ b/apps/edr-freight-web/portal/src/lib/posthog.ts @@ -0,0 +1,151 @@ +/** + * PostHog wiring — session replay, exception capture, console logs. + * + * NOTE: `backoffice/src/lib/posthog.ts` is the twin of this file. The init + * config below (masking rules) and the PII allowlist in `useIdentify` MUST be + * kept identical in both — a change made here alone silently leaks customer + * data into the other app's replays. + * + * This is instrumentation, not analytics: autocapture is off and no product + * events are sent. + */ +import posthog from "posthog-js"; +import { useEffect } from "react"; + +import type { AuthUser } from "@/types/auth"; + +const APP = "freight-portal"; + +const TOKEN = import.meta.env.VITE_POSTHOG_KEY; +const HOST = import.meta.env.VITE_POSTHOG_HOST; + +/** + * Whether init actually ran. Without a token every export below is a no-op, so + * local dev and any environment whose env file lacks the vars keeps working — + * a missing observability token must never break the app. + */ +let enabled = false; + +export function initPostHog(): void { + if (enabled || !TOKEN || !HOST) return; + + posthog.init(TOKEN, { + api_host: HOST, + defaults: "2026-05-30", + + // Debuggability, not analytics. + autocapture: false, + capture_pageview: true, + capture_pageleave: true, + disable_surveys: true, + person_profiles: "identified_only", + + capture_exceptions: { + capture_unhandled_errors: true, + capture_unhandled_rejections: true, + capture_console_errors: true, + }, + + // Off by default; this is half the point of the integration. + enable_recording_console_log: true, + + // Inputs are masked; rendered text stays visible so replays are readable. + // Wrap sensitive elements in `ph-no-capture` to blank them individually. + session_recording: { maskAllInputs: true }, + }); + + // Portal and backoffice share one PostHog project — filter by this. + posthog.register({ app: APP }); + + enabled = true; +} + +export function captureException( + error: unknown, + properties?: Record, +): void { + if (!enabled) return; + posthog.captureException(error, properties); +} + +/** + * Report a failed API call. + * + * Called from the axios interceptor rather than from `emitApiError`, which + * early-returns on suppressed paths (warehouse / first-mile / onboarding / + * auth) — hooking there would drop errors on exactly those pages. + * + * 5xx and network failures are real defects and go to Error tracking. 4xx is + * usually the server correctly rejecting input, so it is recorded as a plain + * event to keep the issue list signal-heavy. + */ +export function captureApiError(error: unknown): void { + if (!enabled) return; + + const err = error as { + message?: string; + config?: { method?: string; url?: string }; + response?: { status?: number }; + }; + + const status = err.response?.status; + const properties = { + api_status: status ?? null, + api_method: err.config?.method?.toUpperCase() ?? null, + api_path: err.config?.url ?? null, + }; + + if (status && status < 500) { + posthog.capture("api_error", properties); + return; + } + + posthog.captureException(error, properties); +} + +/** Company context, as returned by `useAuth().company`. */ +interface IdentifyCompany { + company?: { id?: string; type?: string | null; status?: string | null } | null; + profile?: { activeProfileType?: string | null } | null; +} + +/** + * Identify the current user to PostHog so replays and exceptions are + * attributable. + * + * Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber, + * name and username; these are real customers, and debugging a replay never + * requires knowing how to phone the person in it. + */ +export function useIdentify( + user: AuthUser | null, + company?: IdentifyCompany | null, +): void { + useEffect(() => { + if (!enabled) return; + + if (!user?.id) { + posthog.reset(); + return; + } + + posthog.identify(user.id, { + roles: user.roles, + status: user.status, + user_type: user.userType, + company_id: company?.company?.id, + company_type: company?.company?.type, + company_status: company?.company?.status, + active_profile_type: company?.profile?.activeProfileType, + }); + }, [ + user?.id, + user?.roles, + user?.status, + user?.userType, + company?.company?.id, + company?.company?.type, + company?.company?.status, + company?.profile?.activeProfileType, + ]); +} diff --git a/apps/edr-freight-web/portal/src/main.tsx b/apps/edr-freight-web/portal/src/main.tsx index 44f2d3fb9..983aa5b7a 100644 --- a/apps/edr-freight-web/portal/src/main.tsx +++ b/apps/edr-freight-web/portal/src/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MantineProvider } from "@mantine/core"; +import posthog from "posthog-js"; +import { PostHogProvider, PostHogErrorBoundary } from "@posthog/react"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "@edr/ui-common/styles.css"; @@ -10,6 +12,8 @@ import "../index.css"; import "@edr/ui-common/theme.css"; import { Toaster } from "react-hot-toast"; import { mantineTheme } from "./theme/mantine"; +import { initPostHog } from "./lib/posthog"; +import { AppErrorFallback } from "./components/errors/AppErrorFallback"; import App from "./App"; @@ -26,6 +30,10 @@ import App from "./App"; } }); +// Must run before render so replay and exception capture cover startup errors. +// No-ops when VITE_POSTHOG_KEY is unset. +initPostHog(); + const queryClient = new QueryClient(); const rootElement = document.getElementById("root"); @@ -36,13 +44,17 @@ if (!rootElement) { createRoot(document.getElementById("root")!).render( - - - - - - - - + + + + + }> + + + + + + + , ); diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 0e88c88b3..69dfe308f 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -6,6 +6,7 @@ import { emitApiError, extractApiErrorPayload, } from "@/components/errors/ApiErrorModal"; +import { captureApiError } from "@/lib/posthog"; const client = axios.create({ baseURL: API_BASE_URL, @@ -91,6 +92,14 @@ client.interceptors.response.use( _retry?: boolean; }; + // 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); + } + // Don't intercept if: // - no response (network error) // - status is not 401 diff --git a/apps/edr-freight-web/portal/src/vite-env.d.ts b/apps/edr-freight-web/portal/src/vite-env.d.ts index b24245689..a4955ecee 100644 --- a/apps/edr-freight-web/portal/src/vite-env.d.ts +++ b/apps/edr-freight-web/portal/src/vite-env.d.ts @@ -16,7 +16,13 @@ interface Window { interface ImportMetaEnv { readonly VITE_API_URL: string; + readonly VITE_BASE_API_URL: string; readonly VITE_GOOGLE_MAPS_API_KEY?: string; + readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string; + /** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */ + readonly VITE_POSTHOG_KEY?: string; + /** Self-hosted PostHog instance URL. */ + readonly VITE_POSTHOG_HOST?: string; } interface ImportMeta { diff --git a/docker-compose.yaml b/docker-compose.yaml index 7637c500a..a621749dc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -56,6 +56,8 @@ services: VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} + VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} + VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} secrets: - npmrc ports: @@ -72,6 +74,8 @@ services: VITE_BASE_API_URL: ${VITE_BASE_API_URL:-} VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-} VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-} + VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-} + VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-} secrets: - npmrc ports: diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 6ef9231f0..1e26bf643 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -28,11 +28,15 @@ ARG VITE_BASE_API_URL ARG VITE_USER_MANAGEMENT_BASE ARG NEXT_PUBLIC_API_URL ARG VITE_GOOGLE_MAPS_API_KEY +ARG VITE_POSTHOG_KEY +ARG VITE_POSTHOG_HOST ENV VITE_API_URL=${VITE_API_URL} ENV VITE_BASE_API_URL=${VITE_BASE_API_URL} ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV VITE_GOOGLE_MAPS_API_KEY=${VITE_GOOGLE_MAPS_API_KEY} +ENV VITE_POSTHOG_KEY=${VITE_POSTHOG_KEY} +ENV VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST} RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \ echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97702902c..108b45d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,6 +241,9 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) + '@posthog/react': + specifier: ^1.10.3 + version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6) '@radix-ui/react-accordion': specifier: ^1.2.13 version: 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -409,6 +412,9 @@ importers: pdf-lib: specifier: ^1.17.1 version: 1.17.1 + posthog-js: + specifier: ^1.400.1 + version: 1.400.1 prop-types: specifier: ^15.8.1 version: 15.8.1 @@ -566,12 +572,15 @@ importers: '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) + '@posthog/react': + specifier: ^1.10.3 + version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6) '@tanstack/react-query': specifier: ^5.59.0 version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -590,6 +599,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.17.0(react@19.2.6) + posthog-js: + specifier: ^1.400.1 + version: 1.400.1 radix-ui: specifier: ^1.4.3 version: 1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3125,6 +3137,22 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@posthog/core@1.41.1': + resolution: {integrity: sha512-lKjPdeawDSvRhHnP14RwTSI5CofuyluhG3ISHRa+Kj6PyfSrEUyIkoVOpYWicFGgWikeaJCZzFQpo7ngHt9BcA==} + + '@posthog/react@1.10.3': + resolution: {integrity: sha512-Qu//fGQmVlX0B9kTA3LLg67e7AYLEmeuA0Bf1qSyUM0uUILcRQGjQezhNQPLYSTakOqvXEnl6fM2iQBF6Toxrw==} + peerDependencies: + '@types/react': '>=16.8.0' + posthog-js: '>=1.257.2' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@posthog/types@1.394.0': + resolution: {integrity: sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg==} + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -7050,6 +7078,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -9498,10 +9529,21 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + posthog-js@1.400.1: + resolution: {integrity: sha512-NGfzNwTu+VBw4FekgYs/aQbEkTFkvmpTFUKDGZw/9K6R/sG2WyuLsnnXySRcNH8RMki0Io8v9flNATrrTfmT+Q==} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact@10.29.7: + resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -9601,6 +9643,9 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} + query-selector-shadow-dom@1.0.1: + resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -11478,6 +11523,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-vitals@5.3.0: + resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==} + webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} @@ -13696,6 +13744,19 @@ snapshots: '@popperjs/core@2.11.8': {} + '@posthog/core@1.41.1': + dependencies: + '@posthog/types': 1.394.0 + + '@posthog/react@1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)': + dependencies: + posthog-js: 1.400.1 + react: 19.2.6 + optionalDependencies: + '@types/react': 18.3.31 + + '@posthog/types@1.394.0': {} + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -16092,130 +16153,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -17402,16 +17339,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18015,8 +17942,7 @@ snapshots: core-js@2.6.12: {} - core-js@3.49.0: - optional: true + core-js@3.49.0: {} core-util-is@1.0.3: {} @@ -19202,6 +19128,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.4.8: {} + fflate@0.8.3: {} figures@1.7.0: @@ -21856,8 +21784,23 @@ snapshots: dependencies: xtend: 4.0.2 + posthog-js@1.400.1: + dependencies: + '@posthog/core': 1.41.1 + '@posthog/types': 1.394.0 + core-js: 3.49.0 + dompurify: 3.4.8 + fflate: 0.4.8 + preact: 10.29.7 + query-selector-shadow-dom: 1.0.1 + web-vitals: 5.3.0 + transitivePeerDependencies: + - preact-render-to-string + powershell-utils@0.1.0: {} + preact@10.29.7: {} + prelude-ls@1.2.1: {} prettier@3.8.3: {} @@ -21981,6 +21924,8 @@ snapshots: dependencies: side-channel: 1.1.0 + query-selector-shadow-dom@1.0.1: {} + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -22173,15 +22118,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23371,24 +23307,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24334,6 +24252,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-vitals@5.3.0: {} + webdriver-bidi-protocol@0.4.1: {} webidl-conversions@7.0.0: {} From ff49554cc2517f8832a5a03fad815b6c170fd807 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 08:30:18 +0000 Subject: [PATCH 8/9] feat(last-mile): per-truck EDR arrival/exit, exit paper, bulk drawdown, departure notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EDR last-mile is multi-truck but was modelled as one: setVehicles accepted any number of trucks with no validation, arrival/delivery were stamped once per last_mile record (N trucks shared one timestamp), EDR trucks got no exit paper, and the per-truck EDR handover never happened because deliver() resolved the plate via last_mile_container_allocations — a table nothing writes. - Migration 2260000000000: per-truck arrived_at/departed_at/gross_weight_tons/ net_weight_tons on last_mile_vehicle_assignments, plus a last_mile_vehicle_containers child table (a truck holds 1x40ft OR 2x20ft, so the single container_number scalar could not express a load). Weights are TONNES and named accordingly — the older gross_weight_kg lies about its unit. - setVehicles: enforce the size rule (one 40ft, or two 20ft), container membership, one-container-one-truck, and never more trucks than containers. Bulk carries no containers and is instead gated on tonnage remaining. - Bulk drawdown: remainingTonsForBooking = booking VGM minus the net weighed off every departed truck (both tonnes, no conversion), exposed as GET /last-mile/booking/:bookingId/remaining-tons. - release() now stamps the EDR truck's own arrival and exit (matched by plate, so it works for bulk too) alongside the existing customer-truck stamp. The exit weighing itself is untouched. - New GET /warehouse-inventory/edr-truck-exit-paper/:assignmentId — per-truck exit paper for EDR trucks. Deliberately not signature-gated: EDR handovers are generated at delivery, after the truck has left. - deliver(): resolve the handover plate from the truck's own containers instead of the dead allocations table, so EDR handovers are genuinely per-truck. - Notify the customer (portal inbox + SMS/email) when a truck leaves — one hook in release() covers both self-haul and EDR, since it is the single exit path. Self-haul is intentionally unchanged (one booking-level handover signed once). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...000000-AddLastMileTruckArrivalDeparture.ts | 64 ++++++ .../modules/last-mile/dto/set-vehicles.dto.ts | 24 +- .../last-mile-vehicle-assignment.entity.ts | 29 ++- .../last-mile-vehicle-container.entity.ts | 30 +++ .../modules/last-mile/last-mile.controller.ts | 6 + .../src/modules/last-mile/last-mile.module.ts | 8 +- .../modules/last-mile/last-mile.service.ts | 194 +++++++++++++++- .../warehouse-inventory.controller.ts | 14 ++ .../warehouses/warehouse-inventory.service.ts | 207 ++++++++++++++++-- 9 files changed, 547 insertions(+), 29 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2260000000000-AddLastMileTruckArrivalDeparture.ts create mode 100644 apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-container.entity.ts 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/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..474f943a9 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'; @@ -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/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); } } From d5182c5d85b665c077d543701d298dbb14fb2ffc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 16 Jul 2026 09:03:04 +0000 Subject: [PATCH 9/9] feat(last-mile): wire multi-container assignment, bulk drawdown + EDR exit papers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for the per-truck EDR backend: - Assign modal takes a container LOAD per truck (MultiSelect, max 2) instead of a single container, so a truck can carry one 40ft or two 20ft. Sends containerNumbers[]; the API enforces the size rule. - requiredVehicles is now size-aware: a 40ft fills a truck (1 each), two 20ft share one. It previously assumed ceil(containers / 2) regardless of size, so a 2x40ft booking under-counted the trucks needed. - Bulk bookings show the drawdown in the assign modal — remaining vs total tonnage, what's been hauled, and a "fully hauled" state when it reaches 0. - New "Truck exit papers" row action opens a per-truck list (plate, containers, arrival, exit, net weight) with a per-truck exit-paper download. - last-mile findAll/findById now load each assignment's containers so the UI can hydrate a truck's load. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/last-mile/last-mile.service.ts | 6 +- .../operations/EdrTruckExitPapersModal.tsx | 131 ++++++++++++++++ .../src/pages/operations/LastMilePage.tsx | 148 ++++++++++++++---- .../src/services/last-mile.service.ts | 15 +- .../src/services/warehouse.service.ts | 5 + 5 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/operations/EdrTruckExitPapersModal.tsx 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 474f943a9..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 @@ -182,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, @@ -207,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 }, }, }); @@ -284,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 []; 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} /> -