From 312014b6780cf611eeee48c5763874db0dfe15de Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:13:24 +0000 Subject: [PATCH 1/2] 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 a4b330941215f32e24732bce9e1a07bd7f75b9c1 Mon Sep 17 00:00:00 2001 From: natib21 Date: Thu, 16 Jul 2026 07:43:54 +0000 Subject: [PATCH 2/2] 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;