Merge pull request #552 from Tria-plc/Truckdetantion

Truckdetantion
This commit is contained in:
Hagernesh Tadesse
2026-07-08 22:58:12 +03:00
committed by GitHub
16 changed files with 362 additions and 88 deletions

View File

@@ -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;
`);
}
}

View File

@@ -1424,12 +1424,14 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned handover means the customer must approve delivery.
// Surfaced so the portal shows "Approve delivery" as soon as the handover
// exists, independent of the truck-arrival flag.
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// 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(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

View File

@@ -312,6 +312,13 @@ export class CustomerTruckService {
if (assignment.departedAt) {
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());
if (!requested.length) {
@@ -333,12 +340,16 @@ export class CustomerTruckService {
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
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(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
loadedAt,
}),
),
);

View File

@@ -23,4 +23,12 @@ export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64 })
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;
}

View File

@@ -9,14 +9,19 @@ import {
IsOptional,
IsString,
IsUUID,
Matches,
Min,
ValidateNested,
} from 'class-validator';
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
@ApiProperty()
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumber must match ISO container format, e.g. ABCD1234567',
})
containerNumber!: string;
@ApiPropertyOptional()

View File

@@ -361,6 +361,16 @@ export class WarehouseInventoryController {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get('bookings/:bookingId/container-items')
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {

View File

@@ -2603,12 +2603,13 @@ export class WarehouseInventoryService {
Array<{
containerNumber: string;
goods: string | null;
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
grnNumber: string | null;
truckAssignmentId: string | null;
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
@@ -2624,6 +2625,7 @@ export class WarehouseInventoryService {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
@@ -2637,6 +2639,7 @@ export class WarehouseInventoryService {
a.plate_number AS "truckPlate",
(a.arrived_at IS NOT NULL) AS "truckArrived",
(a.departed_at IS NOT NULL) AS "truckLeft",
(ctc.loaded_at IS NOT NULL) AS loaded,
b.reference AS "bookingReference",
b.contract_id AS "contractId",
(b.last_mile_delivery_address IS NOT NULL) AS "hasLastMile",
@@ -2666,22 +2669,28 @@ export class WarehouseInventoryService {
return rows.map((r) => ({
containerNumber: r.containerNumber,
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
? 'DELIVERED'
: r.truckLeft
? 'LEFT'
: r.truckAssignmentId
: r.loaded
? 'LOADED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
: r.truckAssignmentId
? 'ASSIGNED'
: r.grnNumber
? 'GRN'
: r.received
? 'RECEIVED'
: 'PENDING',
grnNumber: r.grnNumber,
truckAssignmentId: r.truckAssignmentId,
truckPlate: r.truckPlate,
truckArrived: r.truckArrived,
truckLeft: r.truckLeft,
loaded: r.loaded,
bookingReference: r.bookingReference,
contractId: r.contractId,
hasLastMile: r.hasLastMile,
@@ -3016,6 +3025,21 @@ export class WarehouseInventoryService {
};
}
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
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 updated_at DESC NULLS LAST, created_at DESC
LIMIT 1`,
[bookingId],
);
if (!inv) {
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
}
return this.handoverDocument(inv.id);
}
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
@@ -3242,7 +3266,22 @@ export class WarehouseInventoryService {
[item.bookingId],
);
} 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);
}
}
});

View File

@@ -37,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
@@ -46,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
/** 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' || i.stage === 'ASSIGNED';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
@@ -77,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
// Only arrived, not-yet-departed trucks can be loaded.
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}` }));
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.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
{i.loaded && i.truckAssignmentId && (
<Tooltip
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
disabled={i.handoverSigned}

View File

@@ -347,8 +347,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
placeholder="Select the assigned truck"
searchable
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}
disabled={isTruckIdentityLocked}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);

View File

@@ -64,7 +64,7 @@ import type {
WarehouseZone,
} 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 {
containerNumber: string;
@@ -75,6 +75,8 @@ export interface ContainerItem {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;

View File

@@ -1,11 +1,8 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { type MouseEvent, useState } from "react";
import { api } from "@/services/api";
import { ApproveDeliveryModal } from "./ApproveDeliveryModal";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
@@ -13,25 +10,6 @@ type ApproveDeliveryButtonProps = ButtonProps & {
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
@@ -40,56 +18,31 @@ export function ApproveDeliveryButton({
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
downloadBlob(blob, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
navigate("/billing");
}
},
});
const [opened, setOpened] = useState(false);
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
setOpened(true);
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending || handoverMutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
<>
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
onClick={handleClick}
>
Approve delivery
</Button>
<ApproveDeliveryModal
bookingId={bookingId}
opened={opened}
onClose={() => setOpened(false)}
onApproved={onApproved}
/>
</>
);
}

View File

@@ -0,0 +1,171 @@
import { Alert, Button, Group, Loader, Modal, Stack, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Info } from "lucide-react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
type ApproveDeliveryModalProps = {
bookingId: string;
opened: boolean;
onClose: () => void;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
/**
* Approve-delivery flow: open the handover document for the customer to review,
* then apply their saved signature (approve) and hand back the signed PDF.
*/
export function ApproveDeliveryModal({
bookingId,
opened,
onClose,
onApproved,
}: ApproveDeliveryModalProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const {
data: docBlob,
isLoading,
isError,
} = useQuery({
queryKey: ["booking-handover-doc", bookingId],
queryFn: () => bookingsService.downloadBookingHandoverDocument(bookingId),
enabled: opened && Boolean(bookingId),
staleTime: 0,
});
useEffect(() => {
if (!docBlob) {
setPdfUrl(null);
return;
}
const url = URL.createObjectURL(docBlob);
setPdfUrl(url);
return () => URL.revokeObjectURL(url);
}, [docBlob]);
const handoverMutation = useMutation(
api.bookings.downloadHandoverDocument.mutationOptions(),
);
const approve = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async (result) => {
try {
const signed = await handoverMutation.mutateAsync({
inventoryId: result.inventoryId,
});
downloadBlob(signed, `handover-${bookingId}.pdf`);
toast.success("Delivery approved and signed handover downloaded");
} catch {
toast.success("Delivery approved and handover signed");
toast.error("Signed handover document could not be downloaded");
}
await Promise.all([
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: bookingId }),
}),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
onClose();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
onClose();
navigate("/signature");
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
onClose();
navigate("/billing");
}
},
});
const busy = approve.isPending || handoverMutation.isPending;
return (
<Modal
opened={opened}
onClose={onClose}
title="Approve delivery — review & sign the handover"
size="xl"
centered
>
<Stack gap="md">
<Alert color="blue" variant="light" icon={<Info size={16} />}>
<Text size="sm">
Review the handover document below. Approving applies your saved signature
and confirms you received the goods.
</Text>
</Alert>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading handover document
</Text>
</Group>
) : isError || !pdfUrl ? (
<Text size="sm" c="red">
Could not load the handover document. It may not be generated yet.
</Text>
) : (
<iframe
title="Handover document"
src={pdfUrl}
style={{
width: "100%",
height: "60vh",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
/>
)}
<Group justify="flex-end">
<Button variant="default" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={busy}
disabled={isLoading || isError}
onClick={() => approve.mutate({ id: bookingId })}
>
Approve &amp; sign delivery
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -1151,6 +1151,8 @@ function ContainerLineEditor({
render={({ field, fieldState }) => (
<TextInput
{...field}
onChange={(e) => field.onChange(e.currentTarget.value.toUpperCase())}
maxLength={11}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}

View File

@@ -26,8 +26,20 @@ export interface ShipmentValidationContext {
requiresDate?: boolean;
}
// ISO 6346: 4 letters (owner + category) + 6 serial digits + 1 check digit.
// Enforced at booking input so the number stays a clean reference in the warehouse.
const ISO_CONTAINER_RE = /^[A-Z]{4}\d{7}$/;
const containerUnitSchema = z.object({
containerNumber: z.string().min(1, "Container number is required."),
containerNumber: z
.string()
.transform((v) => v.trim().toUpperCase())
.pipe(
z
.string()
.min(1, "Container number is required.")
.regex(ISO_CONTAINER_RE, "Use ISO format: 4 letters + 7 digits, e.g. ABCU1234567."),
),
sealNumber: z.string().default(""),
vgmTons: z
.string()

View File

@@ -264,6 +264,13 @@ export const api = {
bookingsService.downloadHandoverDocument(inventoryId),
),
downloadBookingHandoverDocument: endpoint<{ bookingId: string }, Blob>(
"bookings",
"downloadBookingHandoverDocument",
({ bookingId }) =>
bookingsService.downloadBookingHandoverDocument(bookingId),
),
create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking

View File

@@ -174,6 +174,13 @@ export const bookingsService = {
);
return data;
},
downloadBookingHandoverDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;