fix(warehouses): truck-arrival crash on duplicate containers; auto-GRN on import unload

Truck Arrival died with "[@mantine/core] Duplicate options are not supported"
when a booking carried the same container number on two container lines (the
unit uniqueness is per line, not per booking):
- bookingContainerWeights now GROUPs BY container number (MAX weight)
- the weighing modal dedupes both the containers MultiSelect and the
  assigned-trucks Select defensively, so bad data degrades instead of
  crashing the whole page

Import GRN is now issued automatically when the arrived train is unloaded:
autoUnloadArrivedBookings stamps GRN-IMPORT-<date>-<booking> on both the
create and update paths (never overwrites an existing GRN). The per-row GRN
button in the import queue lights up with no extra clicks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-10 10:13:46 +00:00
parent 00875dd2f4
commit 9d147fbe26
2 changed files with 23 additions and 7 deletions

View File

@@ -1597,6 +1597,8 @@ export class WarehouseInventoryService {
status: 'UNLOADED', status: 'UNLOADED',
unloadedAt: now, unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now, arrivedAt: existing.arrivedAt ?? now,
// Import GRN is issued automatically at train unload.
...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }),
}); });
await this.activityLog.record({ await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED', activityType: 'INVENTORY_UNLOADED',
@@ -1630,6 +1632,7 @@ export class WarehouseInventoryService {
quantity: 1, quantity: 1,
weight: Number(booking.weight) || 0, weight: Number(booking.weight) || 0,
status: 'UNLOADED', status: 'UNLOADED',
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
arrivedAt: now, arrivedAt: now,
unloadedAt: now, unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
@@ -2757,11 +2760,12 @@ export class WarehouseInventoryService {
const rows: Array<{ containerNumber: string; weightTons: string }> = const rows: Array<{ containerNumber: string; weightTons: string }> =
await this.dataSource.query( await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber", `SELECT bcu.container_number AS "containerNumber",
COALESCE(bcu.vgm_tons, 0) AS "weightTons" MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_container bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
GROUP BY bcu.container_number
ORDER BY bcu.container_number`, ORDER BY bcu.container_number`,
[bookingId], [bookingId],
); );

View File

@@ -202,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
]; ];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer // Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the // portal) are selectable. No global fleet list — if nothing is assigned, the
// operator types the plate manually in the field below. // operator types the plate manually in the field below. Deduped by plate:
const truckSelectOptions = assignedTruckOptions; // duplicate option values crash Mantine's Select.
const truckSelectOptions = [
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
];
// Neither a last-mile truck nor a customer truck has been assigned yet. // Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
@@ -216,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const containerWeightByNumber = new Map( const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
); );
const containerSelectData = containerWeights.map((c) => ({ // Mantine Selects throw on duplicate option values — legacy bookings can carry
value: c.containerNumber, // the same container number on two lines, so dedupe defensively.
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, const containerSelectData = [
})); ...new Map(
containerWeights.map((c) => [
c.containerNumber,
{
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
},
]),
).values(),
];
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number( const selectedCargoWeight = Number(
selectedContainerNumbers selectedContainerNumbers