mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1041 from Tria-plc/edrmiles
Carriage Acceptance sheets
This commit is contained in:
@@ -87,6 +87,12 @@ interface CarriageAcceptanceWagonRow {
|
||||
sealNumbers: string | null;
|
||||
}
|
||||
|
||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||
interface CarriageAcceptanceReceivedRow {
|
||||
allocatedWeightTons: string | null;
|
||||
containerNumbers: string | null;
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
@@ -233,7 +239,7 @@ export class BookingsService {
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
@@ -264,13 +270,57 @@ export class BookingsService {
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
if (wagons.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
// this sheet then — before the booking is put on a train. So a received
|
||||
// export booking gets its sheet off the received cargo, wagon columns blank
|
||||
// until the consist exists. Import keeps the allocation gate: nothing is
|
||||
// accepted from the customer before the wagons carry it.
|
||||
//
|
||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||
// the cargo, which is what the customer's sheet attests to.
|
||||
const pendingWagons = wagons.length === 0;
|
||||
if (pendingWagons) {
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
|
||||
ORDER BY inv.created_at`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
|
||||
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
|
||||
);
|
||||
}
|
||||
wagons = receivedLines.map((row, index) => ({
|
||||
sequenceNo: index + 1,
|
||||
wagonType: null,
|
||||
wagonNumber: null,
|
||||
tareWeightTons: null,
|
||||
equatedLength: null,
|
||||
loadCapacityTons: null,
|
||||
allocatedWeightTons: row.allocatedWeightTons,
|
||||
trainNumber: null,
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons);
|
||||
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'carriage acceptance sheet',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
@@ -299,6 +349,7 @@ export class BookingsService {
|
||||
private buildCarriageAcceptanceSheetHtml(
|
||||
booking: Booking,
|
||||
wagons: CarriageAcceptanceWagonRow[],
|
||||
{ pendingWagons }: { pendingWagons: boolean },
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
|
||||
@@ -424,7 +475,11 @@ export class BookingsService {
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})</td>
|
||||
<td colspan="3">${
|
||||
pendingWagons
|
||||
? `Received lines: ${wagons.length} — wagons pending marshalling`
|
||||
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
|
||||
}</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
@@ -435,9 +490,14 @@ export class BookingsService {
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity, container and seal numbers must be verified against the physical consist
|
||||
before the sheet is signed.
|
||||
${
|
||||
pendingWagons
|
||||
? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
|
||||
: `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity, container and seal numbers must be verified against the physical consist
|
||||
before the sheet is signed.`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
|
||||
@@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
required: true,
|
||||
description: "Pre-filled from the truck type — override only for a one-off",
|
||||
},
|
||||
{ name: "pricePerKm", label: "Price per KM (ETB)", type: "number", description: "Haulage rate charged per kilometre" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
@@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
pricePerKm: 0,
|
||||
status: "ACTIVE",
|
||||
availability: "FREE",
|
||||
description: "",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
@@ -561,12 +562,11 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yard, setYard] = useState<string>("");
|
||||
const [zone, setZone] = useState<string>("");
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
// Auto-populate yard and zone from selected warehouse
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
@@ -575,17 +575,18 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
const selectedWarehouseData = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWarehouseData) {
|
||||
setYard(selectedWarehouseData.yard || selectedWarehouseData.code || "");
|
||||
setZone(selectedWarehouseData.zone || "");
|
||||
} else {
|
||||
setYard("");
|
||||
setZone("");
|
||||
}
|
||||
}, [selectedWarehouseData]);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouse]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoneId(null);
|
||||
}, [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh: any) => ({
|
||||
@@ -594,10 +595,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
}))
|
||||
: [];
|
||||
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!containerNumber || !warehouse || !returnedBy) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null;
|
||||
const selectedYard = yards?.find((y) => y.id === yardId);
|
||||
const selectedZone = zones?.find((z) => z.id === zoneId);
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
@@ -610,6 +621,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
containerNumber,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
zone: selectedZone?.name,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
},
|
||||
@@ -622,8 +635,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setWarehouse(null);
|
||||
setYard("");
|
||||
setZone("");
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
setCondition("");
|
||||
setHandoverNote("");
|
||||
onClose();
|
||||
@@ -666,20 +679,24 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
searchable
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="Auto-populated from warehouse"
|
||||
value={yard}
|
||||
disabled
|
||||
readOnly
|
||||
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouse}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder="Auto-populated from warehouse"
|
||||
value={zone}
|
||||
disabled
|
||||
readOnly
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user