mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
separte handover edr lastmile and customer last mile flow
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured import handover records. Replaces the ad-hoc handover notes so a
|
||||||
|
* booking can carry one handover (single truck) or several (one per truck when
|
||||||
|
* multiple trucks are used). Timing differs by mile type:
|
||||||
|
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
|
||||||
|
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
|
||||||
|
*/
|
||||||
|
export class AddBookingHandovers1980000000000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingHandovers1980000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
|
||||||
|
truck_plate varchar(32),
|
||||||
|
mile_type varchar(20) NOT NULL,
|
||||||
|
reference varchar(100) NOT NULL,
|
||||||
|
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
signed_at timestamptz,
|
||||||
|
signed_by_user_id uuid,
|
||||||
|
delivered_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
|
||||||
|
);
|
||||||
|
// At most one live handover per (booking, customer truck). EDR trucks (which
|
||||||
|
// aren't customer_truck_assignments) and per-booking handovers are de-duped
|
||||||
|
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
|
||||||
|
ON freight.booking_handovers (booking_id, truck_assignment_id)
|
||||||
|
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal file
123
apps/edr-freight-api/src/modules/warehouses/handover.service.ts
Normal 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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
|||||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||||
|
import { HandoverService } from './handover.service';
|
||||||
|
|
||||||
@ApiTags('warehouse-inventory')
|
@ApiTags('warehouse-inventory')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly inventoryService: WarehouseInventoryService,
|
private readonly inventoryService: WarehouseInventoryService,
|
||||||
private readonly scheduling: SchedulingReadFacade,
|
private readonly scheduling: SchedulingReadFacade,
|
||||||
|
private readonly handoverService: HandoverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -312,6 +314,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
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')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
|||||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
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). */
|
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||||
@@ -342,6 +343,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly signatures: SignaturesService,
|
private readonly signatures: SignaturesService,
|
||||||
|
private readonly handover: HandoverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2080,9 +2082,14 @@ export class WarehouseInventoryService {
|
|||||||
[item.bookingId],
|
[item.bookingId],
|
||||||
);
|
);
|
||||||
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
|
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(
|
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`,
|
AND deleted_at IS NULL`,
|
||||||
[item.bookingId],
|
[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(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
@@ -2388,7 +2405,9 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
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 {
|
return {
|
||||||
bookingId,
|
bookingId,
|
||||||
inventoryId: item.id,
|
inventoryId: item.id,
|
||||||
@@ -2589,7 +2612,9 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
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');
|
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 receiverName = dto.receiverName.trim();
|
||||||
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
||||||
const weight = Number(item.weight) || 0;
|
const weight = Number(item.weight) || 0;
|
||||||
@@ -2645,6 +2693,27 @@ export class WarehouseInventoryService {
|
|||||||
},
|
},
|
||||||
manager,
|
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);
|
return this.findById(id);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
|
|||||||
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
|
||||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||||
import { WarehouseInventory } from './entities/warehouse-inventory.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 { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||||
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
import { WarehouseLoading } from './entities/warehouse-loading.entity';
|
||||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||||
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseInspectionReport,
|
WarehouseInspectionReport,
|
||||||
WarehouseAllocationRule,
|
WarehouseAllocationRule,
|
||||||
WarehouseFeeRule,
|
WarehouseFeeRule,
|
||||||
|
BookingHandover,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
WarehouseSchedulingAdapterService,
|
WarehouseSchedulingAdapterService,
|
||||||
WarehouseReleaseDocumentService,
|
WarehouseReleaseDocumentService,
|
||||||
SchedulingReadFacade,
|
SchedulingReadFacade,
|
||||||
|
HandoverService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
WarehousesService,
|
WarehousesService,
|
||||||
|
|||||||
@@ -2421,7 +2421,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
loading={busyId === r.id}
|
loading={busyId === r.id}
|
||||||
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
||||||
>
|
>
|
||||||
Dispatch
|
Truck_dispatch
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||||
import { Info, Scale } from 'lucide-react';
|
import { Info, Scale } from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
|||||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
|
const bookingId = item?.booking?.id;
|
||||||
|
// Customer self-haul trucks assigned to this booking via the portal.
|
||||||
|
const { data: customerTrucks = [] } = useQuery({
|
||||||
|
queryKey: ['release-customer-trucks', bookingId],
|
||||||
|
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||||
|
enabled: opened && Boolean(bookingId),
|
||||||
|
});
|
||||||
const [reference, setReference] = useState('');
|
const [reference, setReference] = useState('');
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||||
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const isEntranceLocked = isExitStep;
|
const isEntranceLocked = isExitStep;
|
||||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||||
|
|
||||||
|
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||||
|
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||||
|
const assignedTruckOptions = [
|
||||||
|
...(truckPrefill?.truckPlateNumber
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: truckPrefill.truckPlateNumber,
|
||||||
|
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||||
|
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||||
|
driverName: truckPrefill.driverName ?? '',
|
||||||
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
|
truckType: truckPrefill.truckType ?? '',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...customerTrucks.map((t) => ({
|
||||||
|
value: t.plateNumber,
|
||||||
|
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||||
|
trailerPlate: '',
|
||||||
|
driverName: t.driverName,
|
||||||
|
driverPhone: '',
|
||||||
|
truckType: t.truckType,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const truckSelectOptions = [
|
||||||
|
...assignedTruckOptions,
|
||||||
|
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
||||||
|
value: t.value,
|
||||||
|
label: t.label,
|
||||||
|
trailerPlate: t.trailerPlate,
|
||||||
|
driverName: '',
|
||||||
|
driverPhone: '',
|
||||||
|
truckType: '',
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||||
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
onChange={(e) => setReference(e.currentTarget.value)}
|
onChange={(e) => setReference(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={isEntranceLocked}
|
||||||
/>
|
/>
|
||||||
|
{noTruckAssigned && (
|
||||||
|
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||||
|
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Select
|
<Select
|
||||||
label="Registered first / last-mile truck"
|
label="Registered first / last-mile truck"
|
||||||
placeholder="Select truck or type plate manually below"
|
placeholder="Select truck or type plate manually below"
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
data={truckSelectOptions}
|
||||||
disabled={isTruckIdentityLocked}
|
disabled={isTruckIdentityLocked}
|
||||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||||
setTruckPlateNumber(truck?.value ?? '');
|
setTruckPlateNumber(truck?.value ?? '');
|
||||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||||
|
if (truck?.driverName) setDriverName(truck.driverName);
|
||||||
|
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||||
|
if (truck?.truckType) setTruckType(truck.truckType);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
import { api as apiClient } from '../auth/http';
|
import { api as apiClient } from '../auth/http';
|
||||||
|
|
||||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||||
@@ -67,6 +69,12 @@ const cleanParams = (params: object) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const warehouseService = {
|
export const warehouseService = {
|
||||||
|
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
|
||||||
|
getCustomerTrucks: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await apiClient.get(`/api/bookings/${bookingId}/customer-trucks`);
|
||||||
|
return data?.data ?? data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
// ── Warehouses ──────────────────────────────────────────────────────────
|
// ── Warehouses ──────────────────────────────────────────────────────────
|
||||||
list: (filter?: WarehouseFilter) =>
|
list: (filter?: WarehouseFilter) =>
|
||||||
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
|
||||||
|
|||||||
Reference in New Issue
Block a user