mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('edr-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
|
||||
async edrTruckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
|
||||
@@ -2827,6 +2827,16 @@ export class WarehouseInventoryService {
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||||
// never does.
|
||||
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||||
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||||
const netTons =
|
||||
grossTons != null && tareTons != null
|
||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||
: (exitInspectionDto.netWeight ?? null);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
@@ -2854,6 +2864,23 @@ export class WarehouseInventoryService {
|
||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||
// load + weight are captured on truck departure, not arrival.
|
||||
}
|
||||
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||||
// container so it works for bulk too (bulk trucks carry no container).
|
||||
if (dto.truckPlateNumber?.trim()) {
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.arrived_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[item.bookingId, dto.truckPlateNumber.trim()],
|
||||
);
|
||||
}
|
||||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||
// handover is signed ONCE (before the first truck leaves), even though
|
||||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||
@@ -2874,6 +2901,34 @@ export class WarehouseInventoryService {
|
||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||
}
|
||||
}
|
||||
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
gross_weight_tons = $4,
|
||||
net_weight_tons = $5,
|
||||
updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
dto.gateOutTime ?? null,
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -2892,9 +2947,56 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
});
|
||||
|
||||
// Tell the customer their truck has left — one hook covers BOTH self-haul and
|
||||
// EDR last-mile, since release() is the single exit path for either. Outside
|
||||
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||
if (isTruckLeaving && item.bookingId) {
|
||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
* provider or contact must not break the exit flow.
|
||||
*/
|
||||
private async notifyTruckDeparture(
|
||||
bookingId: string,
|
||||
plateNumber: string | null,
|
||||
netTons: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||||
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck left the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3219,6 +3321,75 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
|
||||
* assignment). Deliberately NOT gated on the handover: EDR handovers are
|
||||
* generated at delivery — i.e. after the truck has already left — so there is
|
||||
* nothing to sign at exit time. Warehouse-fee clearance still applies.
|
||||
*/
|
||||
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [truck] = await this.dataSource.query(
|
||||
`SELECT lm.booking_id AS "bookingId",
|
||||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||
COALESCE(
|
||||
v.assigned_driver_name,
|
||||
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
|
||||
) AS "driverName",
|
||||
v.vehicle_type AS "truckType",
|
||||
va.gross_weight_tons AS "grossWeightKg",
|
||||
va.departed_at AS "departedAt",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE va.id = $1 AND va.deleted_at IS NULL`,
|
||||
[assignmentId],
|
||||
);
|
||||
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
|
||||
|
||||
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 created_at LIMIT 1`,
|
||||
[truck.bookingId],
|
||||
);
|
||||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
// Bulk trucks carry no containers — the table is then empty and the paper
|
||||
// stands on the weighed gross alone.
|
||||
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT vc.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||
FROM freight.last_mile_vehicle_containers vc
|
||||
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
|
||||
ORDER BY vc.container_number`,
|
||||
[assignmentId],
|
||||
);
|
||||
|
||||
const html = this.buildTruckExitPaperHtml({
|
||||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||
bookingReference: truck.bookingReference,
|
||||
customerName: truck.customerName,
|
||||
plateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName ?? '-',
|
||||
truckType: truck.truckType ?? '-',
|
||||
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||
gateOut: truck.departedAt,
|
||||
containers,
|
||||
});
|
||||
return {
|
||||
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTruckExitPaperHtml(data: {
|
||||
reference: string;
|
||||
bookingReference: string;
|
||||
@@ -3734,20 +3905,30 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
} else {
|
||||
// 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).
|
||||
// vehicle from the truck's own container list (the earlier lookup went
|
||||
// through last_mile_container_allocations, which nothing ever writes —
|
||||
// so truckPlate was always null and every booking collapsed to a single
|
||||
// booking-level handover). Bulk has no container, so fall back to the
|
||||
// delivery's single truck; a booking-level handover 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;
|
||||
}
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers cont
|
||||
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||||
WHERE lm.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||||
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||||
LIMIT 1`,
|
||||
[item.bookingId, item.containerId ?? null],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user