mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user