Merge pull request #485 from Tria-plc/CutomerTruckAssign

separte handover edr lastmile and customer last mile flow

Self-haul: handover auto-generated on first truck arrival.
Signed when customer approves delivery 

Fixes
Import train dispatch no longer blocked by loading confirmation (committed 0a170182).
Marshalling / handover / GRN docs no longer masquerade as "Release Order" (decoupled from the release fallback).
Weighing modal now fetches trucks from both last-mile + customer portal, shows "Truck is not assigned yet".
Name fields (warehouse/fee/allocation) → letters-only.
11 build errors = stale package dist → rebuilt @edr/types + @edr/api-common.
jose missing dep → installed.

Features built
Multi-truck self-haul assignment — 1–2 containers/truck, per-truck arrival; entities + migration + service + controller + portal card. Exit paper carries truck info; freight-order PDF lists every truck.
Import vs export split — import: no container pre-select, staff weigh + register load on leaving (departTruck, gross weight, gate-out, locks), exit paper per container; export: pre-selected containers, receive-only.
Per-container GRN + received_to_port — on booking_container_units; auto-marked on receive (export delivery), staff-confirmed GRN (per batch / per booking), staff-only.
Online demurrage/storage pay in customer portal — Telebirr/Waafi pay button on warehouse invoices.
Container-number picker on truck assignment (surfaced booking container numbers).
This commit is contained in:
Hagernesh Tadesse
2026-07-06 15:31:25 +03:00
committed by GitHub
9 changed files with 367 additions and 9 deletions

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
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).
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
export class BookingHandover extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** Customer self-haul truck this handover belongs to; null = per-booking. */
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: 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;
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
mileType!: HandoverMileType;
@Column({ name: 'reference', type: 'varchar', length: 100 })
reference!: string;
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
generatedAt!: Date;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
}

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
/**
* 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).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(private readonly dataSource: DataSource) {}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
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(
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: 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(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}

View File

@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { HandoverService } from './handover.service';
@ApiTags('warehouse-inventory')
@ApiBearerAuth()
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
constructor(
private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade,
private readonly handoverService: HandoverService,
) {}
@Get()
@@ -312,6 +314,12 @@ export class WarehouseInventoryController {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
}
@Get('bookings/:bookingId/handovers')
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -342,6 +343,7 @@ export class WarehouseInventoryService {
private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
) {}
/**
@@ -2080,9 +2082,14 @@ export class WarehouseInventoryService {
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) {
// Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
(await this.handover.isFullySigned(item.bookingId)) ||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
if (usesCustomerTruck && !handoverSigned) {
throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated',
'Customer must sign the handover before the exit paper can be generated',
);
}
}
@@ -2137,6 +2144,16 @@ export class WarehouseInventoryService {
AND deleted_at IS NULL`,
[item.bookingId],
);
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
}
await this.activityLog.record(
{
@@ -2388,7 +2405,9 @@ export class WarehouseInventoryService {
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
// Generic render — NOT the release-order fallback (would mislabel the GRN
// as a "Gate Clearance / Release Order" when Chromium is unavailable).
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Goods Received Note'),
};
}
@@ -2462,6 +2481,10 @@ export class WarehouseInventoryService {
);
});
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
return {
bookingId,
inventoryId: item.id,
@@ -2589,7 +2612,9 @@ export class WarehouseInventoryService {
return {
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
// Generic render — NOT the release-order fallback (would mislabel the
// handover as a "Gate Clearance / Release Order" when Chromium is down).
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Import Goods Handover'),
};
}
@@ -2601,6 +2626,29 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before the goods can be delivered');
}
// Self-haul: the customer's own truck delivers — deliver only after the
// handover is signed AND the truck has left the warehouse holding the goods.
if (item.bookingId) {
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "assignedAt"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (sh?.assignedAt) {
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`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
}
}
const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
const weight = Number(item.weight) || 0;
@@ -2645,6 +2693,27 @@ export class WarehouseInventoryService {
},
manager,
);
// Handover on delivery. EDR last-mile generates its handover HERE (after
// exit, on delivery). Self-haul handovers were generated on arrival —
// stamp them delivered.
if (item.bookingId) {
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
`SELECT customer_truck_assigned_at AS "selfHaul"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (b?.selfHaul) {
await manager.query(
`UPDATE freight.booking_handovers
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
} else {
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
}
}
});
return this.findById(id);

View File

@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { BookingHandover } from './entities/booking-handover.entity';
import { HandoverService } from './handover.service';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport,
WarehouseAllocationRule,
WarehouseFeeRule,
BookingHandover,
]),
BillingModule,
DocumentsModule,
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService,
SchedulingReadFacade,
HandoverService,
],
exports: [
WarehousesService,