Ticket: Warehouse dashboard Needs Attention cards — fix trucks-on-site aging counters (EDR trucks, UNLOADED status) and make each card open the exact list behind its count via new inventory drill-down filters.

This commit is contained in:
Hagernesh
2026-07-21 11:02:03 +00:00
parent b616f520ae
commit 1fae0752a0
16 changed files with 840 additions and 193 deletions

View File

@@ -333,6 +333,8 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}>
> {
const [lm] = await this.lastMileRepository.findAll({
@@ -347,9 +349,11 @@ export class LastMileService {
? lm.vehicleAssignments.map((va) => ({
vehicle: va.vehicle,
containerNumber: va.containerNumber ?? null,
arrivedAt: va.arrivedAt ?? null,
departedAt: va.departedAt ?? null,
}))
: lm.vehicle
? [{ vehicle: lm.vehicle, containerNumber: null }]
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
: [];
const out: Array<{
@@ -361,8 +365,10 @@ export class LastMileService {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}> = [];
for (const { vehicle, containerNumber } of sources) {
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
if (!vehicle) continue;
let driverName = vehicle.assignedDriverName ?? null;
let driverLicense: string | null = null;
@@ -386,6 +392,8 @@ export class LastMileService {
driverPhone,
truckType: vehicle.vehicleType || null,
containerNumber,
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
});
}
return out;

View File

@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import {
WAREHOUSE_INVENTORY_STATUSES,
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
@IsOptional()
@IsString()
dateTo?: string;
// ── KPI drill-down filters ──────────────────────────────────────────────
// Each mirrors one opsStats() counter so a dashboard card's count always
// equals the length of the list it opens.
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
receivedToday?: boolean;
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
@IsOptional()
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
@IsBoolean()
pendingInspection?: boolean;
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsInt()
@Min(1)
agingOverDays?: number;
}

View File

@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
* and signed before the truck leaves; EDR last-mile handovers are generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal on delivery — one signature per truck.
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
edrAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;

View File

@@ -0,0 +1,66 @@
import { WarehouseInventoryService } from './warehouse-inventory.service';
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
const svc = Object.create(WarehouseInventoryService.prototype) as any;
const arrivalA =
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
const arrivalB =
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
describe('per-truck exit inspection blocks', () => {
it('keeps truck A intact when truck B arrives', () => {
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
expect(afterB).toContain('Abebe Lemeno');
expect(afterB).toContain('Suleman Tamrat');
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
expect(afterB.startsWith('Receive note')).toBe(true);
});
it("exit for truck A updates only A's block and preserves arrival data", () => {
const notes = svc.replaceExitInspectionNote(
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
arrivalB,
'3-85957/48562',
);
const dto = svc.preserveTruckArrivalForExit(
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
notes,
);
expect(dto.driverName).toBe('Abebe Lemeno');
expect(dto.tareWeight).toBe(12);
expect(dto.weighingSkipped).toBeUndefined();
const exitNote = svc.buildExitInspectionNote(dto);
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
expect(replaced).toContain('Gross Weight: 40 t');
expect(replaced).toContain('Net Weight: 28 t');
expect(replaced).toContain('Suleman Tamrat'); // B untouched
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
});
it('skipped weighing records the container-derived net in the note', () => {
const dto = {
truckPlateNumber: '3-85957/48562',
driverName: 'Suleman Tamrat',
weighingSkipped: true,
netWeight: 27.5,
gateInTime: '2026-07-21T09:00:00.000Z',
gateOutTime: '2026-07-21T13:00:00.000Z',
};
const note = svc.buildExitInspectionNote(dto);
expect(note).toContain('Weighing: SKIPPED');
expect(note).toContain('Net Weight: 27.5 t');
});
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
const legacy =
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
expect(block).toContain('Abebe Lemeno');
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
expect(replaced).toContain('Receive note');
});
});

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationsService } from '../notifications/notifications.service';
import { sendCompanyChannels } from '../notifications/notify-company.util';
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
* exit paper), signed by the customer in the portal per truck; once every
* handover is signed the delivery auto-completes (inventory / cargo /
* booking → delivered).
*/
@Injectable()
export class HandoverService {
@@ -25,14 +28,22 @@ export class HandoverService {
) {}
/** Tell the customer a handover is ready and needs their signature. */
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
private async notifySignNeeded(
bookingId: string,
reference: string,
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
): Promise<void> {
try {
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!b?.companyId) return;
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
const body =
opts.mileType === 'EDR_LAST_MILE'
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
await this.inbox.notify({
recipients: { companyId: b.companyId },
audience: NotificationAudience.PORTAL,
@@ -117,31 +128,98 @@ export class HandoverService {
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
private async findEdrHandover(
repo: Repository<BookingHandover>,
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
): Promise<BookingHandover | null> {
if (opts.edrAssignmentId) {
const byAssignment = await repo.findOne({
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
});
if (byAssignment) return byAssignment;
}
if (opts.truckPlate) {
return repo.findOne({
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
});
}
return repo.findOne({
where: {
bookingId,
mileType: 'EDR_LAST_MILE',
truckPlate: IsNull(),
edrAssignmentId: IsNull(),
},
});
}
/**
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
* (alongside its exit paper) and ask the customer to sign it from the portal.
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
*/
async ensureForDepartedEdrTruck(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/**
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
* Normally the handover was already generated on truck exit — this only fills
* the delivery timestamp; a handover is created here only for legacy flows
* where the exit was recorded before this feature existed.
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await this.findEdrHandover(repo, bookingId, opts);
if (existing) {
if (!existing.deliveredAt) {
existing.deliveredAt = new Date();
await repo.save(existing);
}
return existing;
}
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
edrAssignmentId: opts.edrAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
@@ -149,6 +227,25 @@ export class HandoverService {
deliveredAt: new Date(),
}),
);
void this.notifySignNeeded(bookingId, reference, {
mileType: 'EDR_LAST_MILE',
truckPlate: opts.truckPlate,
});
return saved;
}
/** Re-send the sign notification for every unsigned handover on the booking. */
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
where: { bookingId, signedAt: IsNull() },
order: { generatedAt: 'ASC' },
});
for (const h of unsigned) {
await this.notifySignNeeded(bookingId, h.reference, {
mileType: h.mileType,
truckPlate: h.truckPlate,
});
}
}
/**
@@ -182,6 +279,27 @@ export class HandoverService {
}
}
/**
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
* fresh handover; idempotent — an already-signed handover is returned as-is.
*/
async sign(
handoverId: string,
userId?: string | null,
signerName?: string | null,
): Promise<BookingHandover> {
const repo = this.dataSource.getRepository(BookingHandover);
const handover = await repo.findOne({ where: { id: handoverId } });
if (!handover) {
throw new NotFoundException(`Handover ${handoverId} not found`);
}
if (handover.signedAt) return handover;
handover.signedAt = new Date();
handover.signedByUserId = userId ?? null;
handover.signerName = signerName?.trim() || null;
return repo.save(handover);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(
bookingId: string,

View File

@@ -1,6 +1,18 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
Between,
DataSource,
EntityManager,
FindManyOptions,
FindOptionsWhere,
ILike,
In,
IsNull,
LessThanOrEqual,
MoreThanOrEqual,
Raw,
} from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { generateGrnNumber } from '../../common/grn.util';
@@ -68,6 +80,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
export interface InventoryInquiryResult {
id: string;
@@ -508,12 +521,20 @@ export class WarehouseInventoryService {
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
((SELECT count(*)::int FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
+
(SELECT count(*)::int 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
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
AND status = ANY($1)
AND created_at < now() - interval '7 days') AS "itemsAging"`,
[this.IN_WAREHOUSE_STATUSES],
);
return {
receivedToday: row?.receivedToday ?? 0,
@@ -525,7 +546,7 @@ export class WarehouseInventoryService {
}
/** In-warehouse statuses used by the dwell / aging metrics. */
private readonly IN_WAREHOUSE_STATUSES = [
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
'RECEIVED',
'UNLOADED',
'STORED',
@@ -953,7 +974,7 @@ export class WarehouseInventoryService {
? LessThanOrEqual(new Date(filter.dateTo))
: undefined;
const base = {
const base: FindOptionsWhere<WarehouseInventory> = {
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
...(filter.yardId ? { yardId: filter.yardId } : {}),
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
@@ -967,6 +988,24 @@ export class WarehouseInventoryService {
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
};
// KPI drill-down filters — predicates mirror opsStats() exactly so the
// dashboard card's count equals the length of the list it opens.
if (filter.receivedToday) {
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
}
if (filter.pendingInspection) {
base.status = 'RECEIVED';
base.inspectionStatus = IsNull();
}
if (filter.agingOverDays) {
if (!filter.status && !filter.pendingInspection) {
base.status = In(this.IN_WAREHOUSE_STATUSES);
}
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
days: filter.agingOverDays,
});
}
const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search
? [
@@ -2972,12 +3011,29 @@ export class WarehouseInventoryService {
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = isTruckLeaving
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
: dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = isTruckLeaving
? this.preserveTruckArrivalForExit(dto, item.notes)
: dto;
// One reference per item — the first truck's arrival mints it, later trucks
// (arrival or exit) reuse it so all exit papers share the release order.
const reference =
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = {
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
};
// Weighbridge skipped on exit: the recorded net still comes from what the
// truck is holding — the summed cargo weight of its selected containers.
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
const selected = (exitInspectionDto.containerNumber ?? '')
.split(/[,;\n]+/)
.map((n) => n.trim())
.filter(Boolean);
if (selected.length) {
const weights = await this.bookingContainerWeights(item.bookingId);
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
const heldTons = Number(
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
);
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
}
}
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
// The load actually leaving on this truck, in TONNES (the weighing UI is in
@@ -2990,12 +3046,46 @@ export class WarehouseInventoryService {
? Math.round((grossTons - tareTons) * 1000) / 1000
: (exitInspectionDto.netWeight ?? null);
// The weight to record on the inventory when this truck leaves: prefer the
// item's own container cargo weight (a truck may carry other items too);
// fall back to the truck's recorded net. Fills an empty weight only.
let recordedItemTons: number | null = null;
if (isTruckLeaving && netTons != null) {
recordedItemTons = netTons;
if (item.containerId && item.bookingId) {
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
[item.containerId],
);
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
if (ownNumber) {
const weights = await this.bookingContainerWeights(item.bookingId);
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
}
}
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
notes: this.replaceExitInspectionNote(
item.notes,
exitInspectionNote,
exitInspectionDto.truckPlateNumber,
),
});
// Even an unweighed truck records the inventory weight it is holding —
// without this the handover/exit papers print "0 t" for skipped weighings.
if (recordedItemTons != null) {
await manager.query(
`UPDATE freight.warehouse_inventory
SET weight = $2, updated_at = NOW()
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
[id, recordedItemTons],
);
}
if (!isTruckLeaving && item.bookingId) {
// Per-truck arrival: mark the customer truck carrying THIS item's
// container as arrived (matched via the physical container number).
@@ -3861,7 +3951,9 @@ export class WarehouseInventoryService {
`SELECT inv.id,
inv.booking_id AS "bookingId",
inv.quantity,
inv.weight,
-- An unweighed item still reports the cargo weight it holds: fall
-- back to the item's container VGM when no weight was recorded.
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
inv.status,
inv.notes,
inv.inspection_status AS "inspectionStatus",
@@ -3913,6 +4005,16 @@ export class WarehouseInventoryService {
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN LATERAL (
SELECT SUM(bcu.vgm_tons) AS tons
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc2
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
WHERE bc2.booking_id = b.id
AND bcu.deleted_at IS NULL
AND (container.container_number IS NULL
OR bcu.container_number = container.container_number)
) item_vgm ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -4009,14 +4111,25 @@ export class WarehouseInventoryService {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
const totalTrucks = Number(trucks?.total ?? 0);
const leftTrucks = Number(trucks?.left ?? 0);
if (leftTrucks === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
// Multi-truck booking: every assigned truck must arrive and leave —
// each is weighed out separately before the goods count as delivered.
if (leftTrucks < totalTrucks) {
throw new BadRequestException(
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
);
}
}
}
@@ -5297,7 +5410,11 @@ export class WarehouseInventoryService {
weighingSkipped ? 'Weighing: SKIPPED' : null,
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
// Skipped weighing still records a net — the cargo weight of the
// containers the truck is holding, resolved by the caller.
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
? null
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
];
@@ -5305,12 +5422,14 @@ export class WarehouseInventoryService {
}
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
const inspection = this.extractExitInspectionNote(notes);
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
if (!inspection) return dto;
return {
...dto,
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
// The submitted plate wins: a legacy block may store a comma-joined list
// of plates, and the exit must be recorded against the ONE truck leaving.
truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
@@ -5324,25 +5443,94 @@ export class WarehouseInventoryService {
};
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
/**
* Split notes into exit-inspection blocks (one per truck, in order) and
* everything else. A block ends at the first line that isn't one of the
* known inspection labels, so appended notes (delivery approval, handover
* marker) are preserved as "other" content instead of being swallowed by
* the block they happen to follow.
*/
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
if (!trimmed) return exitInspectionNote;
const marker = '[Exit Inspection]';
const index = trimmed.lastIndexOf(marker);
if (index < 0) {
return `${trimmed}\n\n${exitInspectionNote}`;
if (!trimmed) return { others: [], blocks: [] };
const labelPattern =
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
const others: string[] = [];
const blocks: string[] = [];
if (parts[0]?.trim()) others.push(parts[0].trim());
for (const part of parts.slice(1)) {
const lines = part.split('\n');
const kept: string[] = [];
let i = 0;
while (i < lines.length && !lines[i].trim()) i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i].trim();
if (!line || !labelPattern.test(line)) break;
kept.push(line);
}
if (kept.length) blocks.push(kept.join('\n'));
const tail = lines.slice(i).join('\n').trim();
if (tail) others.push(tail);
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
return { others, blocks };
}
/**
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
* legacy comma-joined list ("P1, P2") containing it.
*/
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
const plate = plateNumber?.trim().toUpperCase();
if (!plate) return false;
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
if (!stored) return false;
if (stored === plate) return true;
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
}
/**
* Replace THIS truck's inspection block (matched by plate), keeping every
* other truck's block untouched; append when the plate has no block yet.
* A single legacy block (comma-joined plates or plate-less caller) is
* replaced in place so old single-truck items keep their behaviour.
*/
private replaceExitInspectionNote(
notes: string | null | undefined,
exitInspectionNote: string | null,
plateNumber?: string | null,
): string | null {
const { others, blocks } = this.splitExitInspectionSections(notes);
if (exitInspectionNote) {
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
const index = plateNumber
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
: blocks.length - 1;
if (index >= 0) blocks[index] = content;
else blocks.push(content);
}
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
return sections.join('\n\n') || null;
}
/** Latest truck's inspection block — legacy summary for documents. */
private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null;
const marker = '[Exit Inspection]';
const index = notes.lastIndexOf(marker);
if (index < 0) return null;
return notes.slice(index + marker.length).trim() || null;
const { blocks } = this.splitExitInspectionSections(notes);
return blocks.length ? blocks[blocks.length - 1] : null;
}
/**
* The inspection block for one truck. Falls back to a lone existing block so
* legacy single-truck items (saved before per-plate blocks) keep working.
*/
private extractExitInspectionForPlate(
notes: string | null | undefined,
plateNumber?: string | null,
): string | null {
const { blocks } = this.splitExitInspectionSections(notes);
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
if (match) return match;
return blocks.length === 1 ? blocks[0] : null;
}
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {