mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 08:18:20 +00:00
Fix customer_truck_containers status
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds customer_truck_containers.loaded_at so an assignment (customer planning
|
||||||
|
* which containers ride which truck) is distinct from the container actually
|
||||||
|
* being loaded. Stage LOADED now requires loaded_at; customer assignment alone
|
||||||
|
* keeps the container at its prior stage (RECEIVED/GRN) with its planned truck
|
||||||
|
* shown. Backfills containers on already-departed trucks (they left loaded).
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckContainerLoadedAt2050000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers
|
||||||
|
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.customer_truck_containers ctc
|
||||||
|
SET loaded_at = a.departed_at
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
WHERE a.id = ctc.assignment_id
|
||||||
|
AND a.departed_at IS NOT NULL
|
||||||
|
AND ctc.deleted_at IS NULL
|
||||||
|
AND ctc.loaded_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_containers DROP COLUMN IF EXISTS loaded_at;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1424,12 +1424,14 @@ export class BookingsService {
|
|||||||
schedule?.status ?? null;
|
schedule?.status ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A generated-but-unsigned handover means the customer must approve delivery.
|
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||||
// Surfaced so the portal shows "Approve delivery" as soon as the handover
|
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||||
// exists, independent of the truck-arrival flag.
|
// handovers are per delivering truck and signed by the receiver at the door,
|
||||||
|
// so they never surface the portal "Approve delivery" action.
|
||||||
const [pendingHandover] = await this.dataSource.query(
|
const [pendingHandover] = await this.dataSource.query(
|
||||||
`SELECT 1 FROM freight.booking_handovers
|
`SELECT 1 FROM freight.booking_handovers
|
||||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||||
|
AND mile_type = 'SELF_HAUL'
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -312,6 +312,13 @@ export class CustomerTruckService {
|
|||||||
if (assignment.departedAt) {
|
if (assignment.departedAt) {
|
||||||
throw new ConflictException('This truck has already left — its load is locked');
|
throw new ConflictException('This truck has already left — its load is locked');
|
||||||
}
|
}
|
||||||
|
// Containers can only be loaded after the truck has physically arrived at the
|
||||||
|
// warehouse (arrival weighing recorded). Assignment alone is just planning.
|
||||||
|
if (!assignment.arrivedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Record the truck arrival before loading — containers can only be loaded onto an arrived truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
if (!requested.length) {
|
if (!requested.length) {
|
||||||
@@ -333,12 +340,16 @@ export class CustomerTruckService {
|
|||||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
// Operator loading the truck: stamp loaded_at so these containers move to
|
||||||
|
// the LOADED stage (customer assignment alone leaves loaded_at null).
|
||||||
|
const loadedAt = new Date();
|
||||||
await manager.getRepository(CustomerTruckContainer).save(
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
requested.map((containerNumber) =>
|
requested.map((containerNumber) =>
|
||||||
manager.getRepository(CustomerTruckContainer).create({
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
assignmentId,
|
assignmentId,
|
||||||
bookingId,
|
bookingId,
|
||||||
containerNumber,
|
containerNumber,
|
||||||
|
loadedAt,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
containerNumber!: string;
|
containerNumber!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the container was actually loaded onto the truck by the operator.
|
||||||
|
* Null = customer-assigned (planned) but not yet loaded. Stage LOADED requires
|
||||||
|
* this to be set, so customer assignment alone does not mark a container loaded.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||||
|
loadedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2603,12 +2603,13 @@ export class WarehouseInventoryService {
|
|||||||
Array<{
|
Array<{
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
goods: string | null;
|
goods: string | null;
|
||||||
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
grnNumber: string | null;
|
grnNumber: string | null;
|
||||||
truckAssignmentId: string | null;
|
truckAssignmentId: string | null;
|
||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2624,6 +2625,7 @@ export class WarehouseInventoryService {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
@@ -2637,6 +2639,7 @@ export class WarehouseInventoryService {
|
|||||||
a.plate_number AS "truckPlate",
|
a.plate_number AS "truckPlate",
|
||||||
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
(a.arrived_at IS NOT NULL) AS "truckArrived",
|
||||||
(a.departed_at IS NOT NULL) AS "truckLeft",
|
(a.departed_at IS NOT NULL) AS "truckLeft",
|
||||||
|
(ctc.loaded_at IS NOT NULL) AS loaded,
|
||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.contract_id AS "contractId",
|
b.contract_id AS "contractId",
|
||||||
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
|
||||||
@@ -2666,22 +2669,28 @@ export class WarehouseInventoryService {
|
|||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
goods: r.goods,
|
goods: r.goods,
|
||||||
|
// A container the customer assigned to a truck is ASSIGNED (planned); it
|
||||||
|
// only becomes LOADED once the operator loads it (loaded_at) on truck
|
||||||
|
// leaving. Departed → LEFT, delivered → DELIVERED.
|
||||||
stage: r.delivered
|
stage: r.delivered
|
||||||
? 'DELIVERED'
|
? 'DELIVERED'
|
||||||
: r.truckLeft
|
: r.truckLeft
|
||||||
? 'LEFT'
|
? 'LEFT'
|
||||||
: r.truckAssignmentId
|
: r.loaded
|
||||||
? 'LOADED'
|
? 'LOADED'
|
||||||
: r.grnNumber
|
: r.truckAssignmentId
|
||||||
? 'GRN'
|
? 'ASSIGNED'
|
||||||
: r.received
|
: r.grnNumber
|
||||||
? 'RECEIVED'
|
? 'GRN'
|
||||||
: 'PENDING',
|
: r.received
|
||||||
|
? 'RECEIVED'
|
||||||
|
: 'PENDING',
|
||||||
grnNumber: r.grnNumber,
|
grnNumber: r.grnNumber,
|
||||||
truckAssignmentId: r.truckAssignmentId,
|
truckAssignmentId: r.truckAssignmentId,
|
||||||
truckPlate: r.truckPlate,
|
truckPlate: r.truckPlate,
|
||||||
truckArrived: r.truckArrived,
|
truckArrived: r.truckArrived,
|
||||||
truckLeft: r.truckLeft,
|
truckLeft: r.truckLeft,
|
||||||
|
loaded: r.loaded,
|
||||||
bookingReference: r.bookingReference,
|
bookingReference: r.bookingReference,
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
hasLastMile: r.hasLastMile,
|
hasLastMile: r.hasLastMile,
|
||||||
@@ -3257,7 +3266,22 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
|
// 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).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: 'ALL', label: 'All' },
|
{ value: 'ALL', label: 'All' },
|
||||||
{ value: 'RECEIVED', label: 'Received' },
|
{ value: 'RECEIVED', label: 'Received' },
|
||||||
{ value: 'GRN', label: "GRN'd" },
|
{ value: 'GRN', label: "GRN'd" },
|
||||||
|
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||||
{ value: 'LOADED', label: 'Loaded' },
|
{ value: 'LOADED', label: 'Loaded' },
|
||||||
{ value: 'LEFT', label: 'Left' },
|
{ value: 'LEFT', label: 'Left' },
|
||||||
{ value: 'DELIVERED', label: 'Delivered' },
|
{ value: 'DELIVERED', label: 'Delivered' },
|
||||||
@@ -46,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
|||||||
PENDING: 'gray',
|
PENDING: 'gray',
|
||||||
RECEIVED: 'blue',
|
RECEIVED: 'blue',
|
||||||
GRN: 'teal',
|
GRN: 'teal',
|
||||||
|
ASSIGNED: 'indigo',
|
||||||
LOADED: 'grape',
|
LOADED: 'grape',
|
||||||
LEFT: 'orange',
|
LEFT: 'orange',
|
||||||
DELIVERED: 'green',
|
DELIVERED: 'green',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Loadable = not yet on a truck (before LOADED). */
|
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
const isLoadable = (i: ContainerItem) =>
|
||||||
|
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||||
|
|
||||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||||
[items, tab],
|
[items, tab],
|
||||||
);
|
);
|
||||||
|
// Only arrived, not-yet-departed trucks can be loaded.
|
||||||
const truckOptions = trucks
|
const truckOptions = trucks
|
||||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||||
|
!(t as { departedAt?: string }).departedAt,
|
||||||
|
)
|
||||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||||
|
|
||||||
const loadMutation = useMutation({
|
const loadMutation = useMutation({
|
||||||
@@ -181,7 +189,7 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
|||||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
{i.truckAssignmentId && (
|
{i.loaded && i.truckAssignmentId && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||||
disabled={i.handoverSigned}
|
disabled={i.handoverSigned}
|
||||||
|
|||||||
@@ -347,8 +347,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
placeholder="Select the assigned truck"
|
placeholder="Select the assigned truck"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
|
// Enabled at arrival so the operator picks which assigned truck came;
|
||||||
|
// only locked on the exit (leaving) step once identity is captured.
|
||||||
|
disabled={isEntranceLocked}
|
||||||
data={truckSelectOptions}
|
data={truckSelectOptions}
|
||||||
disabled={isTruckIdentityLocked}
|
|
||||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ import type {
|
|||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
|
||||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||||
|
|
||||||
export interface ContainerItem {
|
export interface ContainerItem {
|
||||||
containerNumber: string;
|
containerNumber: string;
|
||||||
@@ -75,6 +75,8 @@ export interface ContainerItem {
|
|||||||
truckPlate: string | null;
|
truckPlate: string | null;
|
||||||
truckArrived: boolean;
|
truckArrived: boolean;
|
||||||
truckLeft: boolean;
|
truckLeft: boolean;
|
||||||
|
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||||
|
loaded: boolean;
|
||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
contractId: string | null;
|
contractId: string | null;
|
||||||
hasLastMile: boolean;
|
hasLastMile: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user