Goods recieved notes and Booking delivery

This commit is contained in:
hagiye
2026-06-30 07:19:12 +03:00
parent 64cab3ecd0
commit 9f5c22c1ee
21 changed files with 1126 additions and 136 deletions

View File

@@ -18,6 +18,7 @@
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
"seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts",
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface {
name = 'AddGrnNumberToWarehouseInventory1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL
`);
await queryRunner.query(`
UPDATE freight.warehouse_inventory
SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)')
WHERE grn_number IS NULL
AND notes IS NOT NULL
AND notes ~ 'GRN Number: '
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number
ON freight.warehouse_inventory(grn_number)
WHERE grn_number IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`);
await queryRunner.query(`
ALTER TABLE freight.warehouse_inventory
DROP COLUMN IF EXISTS grn_number
`);
}
}

View File

@@ -110,6 +110,9 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
volume?: number | null; volume?: number | null;
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
status!: WarehouseInventoryStatus; status!: WarehouseInventoryStatus;

View File

@@ -273,6 +273,16 @@ export class WarehouseInventoryController {
return res.send(buffer); return res.send(buffer);
} }
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocument(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/handover-document') @Get(':id/handover-document')
@ApiOperation({ summary: 'View import goods handover document PDF' }) @ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {

View File

@@ -52,6 +52,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
export interface InventoryInquiryResult { export interface InventoryInquiryResult {
id: string; id: string;
@@ -249,6 +250,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null; containerNumber: string | null;
cargoType: string | null; cargoType: string | null;
weight: number | null; weight: number | null;
grnNumber: string | null;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
inspectionStatus: string | null; inspectionStatus: string | null;
@@ -295,6 +297,7 @@ export interface ImportUnloadedRow {
containerNumber: string | null; containerNumber: string | null;
cargoType: string | null; cargoType: string | null;
weight: number | null; weight: number | null;
grnNumber: string | null;
trainSchedule: string | null; trainSchedule: string | null;
inspectionStatus: string | null; inspectionStatus: string | null;
pickupOption: string; pickupOption: string;
@@ -302,6 +305,8 @@ export interface ImportUnloadedRow {
currentStatus: string; currentStatus: string;
releaseDate: string | null; releaseDate: string | null;
releaseOrderReference: string | null; releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null; deliveredAt: string | null;
} }
@@ -415,7 +420,10 @@ export class WarehouseInventoryService {
const search = filter.search?.trim(); const search = filter.search?.trim();
const where: FindManyOptions<WarehouseInventory>['where'] = search const where: FindManyOptions<WarehouseInventory>['where'] = search
? { ...base, notes: ILike(`%${search}%`) } ? [
{ ...base, notes: ILike(`%${search}%`) },
{ ...base, grnNumber: ILike(`%${search}%`) },
]
: base; : base;
const items = await this.inventoryRepository.findAll({ const items = await this.inventoryRepository.findAll({
@@ -766,6 +774,7 @@ export class WarehouseInventoryService {
const [booking] = await manager.query( const [booking] = await manager.query(
`SELECT b.reference AS "reference", `SELECT b.reference AS "reference",
b.payment_status AS "paymentStatus", b.payment_status AS "paymentStatus",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weight", b.cargo_total_weight_vgm AS "weight",
company.name AS "customer", company.name AS "customer",
company.tin AS "customerTin", company.tin AS "customerTin",
@@ -847,6 +856,12 @@ export class WarehouseInventoryService {
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; } if (existing) { skip('Already received'); continue; }
const containerQuantity = Number(booking.containerQuantity ?? 0);
if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) {
skip('Container booking has no container quantity');
continue;
}
const now = new Date(); const now = new Date();
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
const truckEntrance = dto.truckEntrance const truckEntrance = dto.truckEntrance
@@ -867,8 +882,9 @@ export class WarehouseInventoryService {
yardId: dto.yardId, yardId: dto.yardId,
zoneId: dto.zoneId, zoneId: dto.zoneId,
bookingId, bookingId,
quantity: Number(booking.containerQuantity) || 1, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1,
weight: Number(booking.weight) || 0, weight: Number(booking.weight) || 0,
grnNumber,
status: 'RECEIVED', status: 'RECEIVED',
arrivedAt: now, arrivedAt: now,
notes: receiveNote, notes: receiveNote,
@@ -962,6 +978,7 @@ export class WarehouseInventoryService {
ct.container_number AS "containerNumber", ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight", inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
oy.code AS "origin", oy.code AS "origin",
dy.code AS "destination", dy.code AS "destination",
oy.country AS "originCountry", oy.country AS "originCountry",
@@ -1021,6 +1038,7 @@ export class WarehouseInventoryService {
ORDER BY c.container_number LIMIT 1) AS "containerNumber", ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight", inv.weight AS "weight",
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
ts.train_number AS "trainSchedule", ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus", inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL CASE WHEN b.last_mile_delivery_address IS NOT NULL
@@ -1029,6 +1047,8 @@ export class WarehouseInventoryService {
inv.status AS "currentStatus", inv.status AS "currentStatus",
inv.release_date AS "releaseDate", inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference", inv.release_order_reference AS "releaseOrderReference",
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
inv.delivered_at AS "deliveredAt", inv.delivered_at AS "deliveredAt",
oy.country AS "originCountry", oy.country AS "originCountry",
dy.country AS "destinationCountry" dy.country AS "destinationCountry"
@@ -1669,6 +1689,7 @@ export class WarehouseInventoryService {
quantity, quantity,
weight, weight,
volume: dto.volume ?? null, volume: dto.volume ?? null,
grnNumber,
status: 'RECEIVED', status: 'RECEIVED',
arrivedAt: now, arrivedAt: now,
notes: receiveNote, notes: receiveNote,
@@ -1933,24 +1954,31 @@ export class WarehouseInventoryService {
); );
} }
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
const reference = dto.reference?.trim() || null; const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null;
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionNote = this.buildExitInspectionNote(dto); const exitInspectionNote = this.buildExitInspectionNote(dto);
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, { await manager.getRepository(WarehouseInventory).update(id, {
releaseDate, releaseDate,
releaseOrderReference: reference, releaseOrderReference: reference,
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'), notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
}); });
await this.activityLog.record( await this.activityLog.record(
{ {
activityType: 'INVENTORY_RELEASED', activityType: 'INVENTORY_RELEASED',
inventoryId: id, inventoryId: id,
warehouseId: item.warehouseId, warehouseId: item.warehouseId,
description: reference description: isTruckLeaving
? `Release order ${reference} sent to customer` ? reference
: 'Release order sent to customer', ? `Exit paper ${reference} generated`
: 'Exit paper generated'
: reference
? `Truck arrival ${reference} registered`
: 'Truck arrival registered',
performedBy: dto.performedBy, performedBy: dto.performedBy,
}, },
manager, manager,
@@ -2039,6 +2067,106 @@ export class WarehouseInventoryService {
} }
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(
`SELECT inv.id,
COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber",
COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt",
inv.quantity,
inv.weight,
inv.volume,
inv.status,
inv.notes,
b.id AS "bookingId",
b.reference AS "bookingReference",
b.status AS "bookingStatus",
b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
company.name AS "customerName",
company.tin AS "customerTin",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName",
wh.code AS "warehouseCode",
yard.name AS "yardName",
yard.code AS "yardCode",
zone.name AS "zoneName",
zone.code AS "zoneCode"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT MIN(bc.container_number) AS container_number,
STRING_AGG(
CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container 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)
WHERE inv.id = $1 AND inv.deleted_at IS NULL
LIMIT 1`,
[id],
);
if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`);
}
if (!row.grnNumber) {
throw new BadRequestException('GRN number is missing for this inventory item');
}
const html = this.buildGrnDocumentHtml({
grnNumber: row.grnNumber,
receivedAt: row.receivedAt ? new Date(row.receivedAt) : new Date(),
bookingReference: row.bookingReference ?? row.bookingId ?? 'N/A',
bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null,
customerTin: row.customerTin ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0),
volume: row.volume == null ? null : Number(row.volume),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
inventoryStatus: row.status ?? null,
receiveSummary: this.extractReceiveSummary(row.notes),
});
return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
};
}
async approveDeliveryForBooking( async approveDeliveryForBooking(
bookingId: string, bookingId: string,
userId?: string, userId?: string,
@@ -2121,8 +2249,17 @@ export class WarehouseInventoryService {
b.status AS "bookingStatus", b.status AS "bookingStatus",
b.freight_type AS "freightType", b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection", b.trade_direction AS "tradeDirection",
b.scheduled_date AS "scheduledDate",
b.cargo_total_weight_vgm AS "bookingDeclaredWeight",
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
company.name AS "customerName", company.name AS "customerName",
service_type.service_name AS "serviceType",
origin_yard.label AS "originYardLabel",
origin_yard.code AS "originYardCode",
destination_yard.label AS "destinationYardLabel",
destination_yard.code AS "destinationYardCode",
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
booking_container."containerSummary" AS "bookingContainerSummary",
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
wh.name AS "warehouseName", wh.name AS "warehouseName",
wh.code AS "warehouseCode", wh.code AS "warehouseCode",
@@ -2134,14 +2271,25 @@ export class WarehouseInventoryService {
FROM freight.warehouse_inventory inv FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.service_types service_type ON service_type.id = b.service_type_id
LEFT JOIN freight.yards origin_yard ON origin_yard.id = b.origin_yard_id
LEFT JOIN freight.yards destination_yard ON destination_yard.id = b.destination_yard_id
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
LEFT JOIN freight.booking_container booking_container ON ( LEFT JOIN LATERAL (
booking_container.booking_id = b.id SELECT MIN(bc.container_number) AS container_number,
AND booking_container.deleted_at IS NULL STRING_AGG(
) CONCAT_WS(' ', bc.quantity::text, COALESCE(ct.label, ct.code, 'container')),
', '
ORDER BY COALESCE(ct.label, ct.code, bc.container_type_id::text)
) AS "containerSummary"
FROM freight.booking_container bc
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
WHERE bc.booking_id = b.id
AND bc.deleted_at IS NULL
) booking_container ON true
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL 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.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 LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
@@ -2158,18 +2306,37 @@ export class WarehouseInventoryService {
} }
const bookingReference = row.bookingReference || row.bookingId || 'N/A'; const bookingReference = row.bookingReference || row.bookingId || 'N/A';
const reference =
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
if (!generatedAtValue) {
await this.inventoryRepository.update(id, {
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
});
}
const html = this.buildHandoverDocumentHtml({ const html = this.buildHandoverDocumentHtml({
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`, reference,
handedOverAt: new Date(row.handoverDate ?? Date.now()), handedOverAt,
bookingReference, bookingReference,
bookingStatus: row.bookingStatus ?? null, bookingStatus: row.bookingStatus ?? null,
customerName: row.customerName ?? null, customerName: row.customerName ?? null,
serviceType: row.serviceType ?? null,
freightType: row.freightType ?? null, freightType: row.freightType ?? null,
tradeDirection: row.tradeDirection ?? null, tradeDirection: row.tradeDirection ?? null,
route: [row.originYardLabel ?? row.originYardCode, row.destinationYardLabel ?? row.destinationYardCode]
.filter(Boolean)
.join(' to ') || null,
scheduledDate: row.scheduledDate ? new Date(row.scheduledDate) : null,
containerNumber: row.containerNumber ?? null, containerNumber: row.containerNumber ?? null,
bookingContainerSummary: row.bookingContainerSummary ?? null,
cargoDescription: row.cargoDescription ?? null, cargoDescription: row.cargoDescription ?? null,
quantity: Number(row.quantity ?? 0), quantity: Number(row.quantity ?? 0),
weight: Number(row.weight ?? 0), weight: Number(row.weight ?? 0),
bookingDeclaredWeight: Number(row.bookingDeclaredWeight ?? 0),
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null, warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null, yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null, zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
@@ -2178,11 +2345,12 @@ export class WarehouseInventoryService {
releaseOrderReference: row.releaseOrderReference ?? null, releaseOrderReference: row.releaseOrderReference ?? null,
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
trainSchedule: row.trainSchedule ?? null, trainSchedule: row.trainSchedule ?? null,
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
customerApproval: this.extractCustomerDeliveryApproval(row.notes), customerApproval: this.extractCustomerDeliveryApproval(row.notes),
}); });
return { return {
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
}; };
} }
@@ -2666,6 +2834,128 @@ export class WarehouseInventoryService {
return this.findById(id); return this.findById(id);
} }
private buildGrnDocumentHtml(data: {
grnNumber: string;
receivedAt: Date;
bookingReference: string;
bookingStatus: string | null;
customerName: string | null;
customerTin: string | null;
serviceType: string | null;
freightType: string | null;
tradeDirection: string | null;
route: string | null;
containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null;
quantity: number;
weight: number;
volume: number | null;
bookingDeclaredWeight: number;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryStatus: string | null;
receiveSummary: string | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const receivedAt = data.receivedAt.toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const rows: Array<[string, unknown]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName],
['Customer TIN', data.customerTin],
['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection],
['Route', data.route],
['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Received Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Volume', data.volume == null ? null : data.volume.toLocaleString()],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
...(data.receiveSummary ? [['Receive Details', data.receiveSummary] as [string, string]] : []),
];
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Goods Received Note</title>
<style>
* { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; }
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
.top { display: grid; grid-template-columns: 1fr 210px; gap: 24px; border-top: 5px solid #0f766e; padding-top: 18px; }
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
h1 { margin: 8px 0 0; font-size: 31px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
.rule { height: 3px; background: #0f766e; margin: 16px 0 22px; }
.notice { width: 76%; margin: 0 0 18px; padding: 13px 18px; background: #f0fdfa; border: 1px solid #5eead4; border-left: 5px solid #0f766e; font-size: 13px; line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #0f766e; text-transform: uppercase; letter-spacing: .12em; }
table { width: 100%; border-collapse: collapse; }
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; white-space: pre-line; }
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 34px; align-items: start; margin-top: 42px; }
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 42px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Goods Received Note</h1>
<div class="subtitle">Warehouse receiving confirmation</div>
</div>
<div class="ref">
GRN Number
<strong>${esc(data.grnNumber)}</strong>
Received: ${esc(receivedAt)}
</div>
</div>
<div class="rule"></div>
<div class="notice">
This Goods Received Note confirms that the listed goods were received into EDR warehouse custody at the stated location.
</div>
<div class="section-title">Receiving Particulars</div>
<table>
<tbody>
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
</tbody>
</table>
<div class="section-title">Receipt Clause</div>
<div class="clause">
This document records warehouse receipt only. Loading, dispatch, release, delivery, customs, and fee clearance remain subject to their respective operational approvals.
</div>
<div class="signatures">
<div class="line">Warehouse receiver name / signature / date</div>
<div class="line">Driver or customer representative name / signature / date</div>
</div>
</body>
</html>`;
}
private buildReleaseDocumentHtml(data: { private buildReleaseDocumentHtml(data: {
reference: string; reference: string;
issuedAt: Date; issuedAt: Date;
@@ -2721,7 +3011,7 @@ export class WarehouseInventoryService {
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Warehouse Gate Clearance / Release Order</title> <title>Warehouse Release / Exit Paper</title>
<style> <style>
* { box-sizing: border-box; } * { box-sizing: border-box; }
@page { size: A4; margin: 12mm 15mm 14mm; } @page { size: A4; margin: 12mm 15mm 14mm; }
@@ -2752,8 +3042,8 @@ export class WarehouseInventoryService {
<div class="top"> <div class="top">
<div> <div>
<div class="brand">Ethio-Djibouti Railway S.C.</div> <div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Gate Clearance / Release Order</h1> <h1>Warehouse Release / Exit Paper</h1>
<div class="subtitle">Official warehouse release and exit authorization</div> <div class="subtitle">Official gate clearance and warehouse exit authorization</div>
</div> </div>
<div class="ref"> <div class="ref">
Document / Release No. Document / Release No.
@@ -2763,7 +3053,7 @@ export class WarehouseInventoryService {
</div> </div>
<div class="rule"></div> <div class="rule"></div>
<div class="notice"> <div class="notice">
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid. This Exit Paper confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
</div> </div>
<div class="section-title">Release Particulars</div> <div class="section-title">Release Particulars</div>
<table> <table>
@@ -2792,12 +3082,17 @@ export class WarehouseInventoryService {
bookingReference: string; bookingReference: string;
bookingStatus: string | null; bookingStatus: string | null;
customerName: string | null; customerName: string | null;
serviceType: string | null;
freightType: string | null; freightType: string | null;
tradeDirection: string | null; tradeDirection: string | null;
route: string | null;
scheduledDate: Date | null;
containerNumber: string | null; containerNumber: string | null;
bookingContainerSummary: string | null;
cargoDescription: string | null; cargoDescription: string | null;
quantity: number; quantity: number;
weight: number; weight: number;
bookingDeclaredWeight: number;
warehouse: string | null; warehouse: string | null;
yard: string | null; yard: string | null;
zone: string | null; zone: string | null;
@@ -2806,6 +3101,7 @@ export class WarehouseInventoryService {
releaseOrderReference: string | null; releaseOrderReference: string | null;
releaseDate: Date | null; releaseDate: Date | null;
trainSchedule: string | null; trainSchedule: string | null;
lastMileDeliveryAddress: string | null;
customerApproval: { customerApproval: {
approvedAt: string; approvedAt: string;
signerDisplayName: string; signerDisplayName: string;
@@ -2835,13 +3131,18 @@ export class WarehouseInventoryService {
['Booking Reference', data.bookingReference], ['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName], ['Customer / Consignee', data.customerName],
['Booking Status', data.bookingStatus], ['Booking Status', data.bookingStatus],
['Service Type', data.serviceType],
['Freight Type', data.freightType], ['Freight Type', data.freightType],
['Trade Direction', data.tradeDirection], ['Trade Direction', data.tradeDirection],
['Route', data.route],
['Scheduled Date', fmt(data.scheduledDate)],
['Train Schedule', data.trainSchedule], ['Train Schedule', data.trainSchedule],
['Container Number', data.containerNumber], ['Container Number', data.containerNumber],
['Booking Containers', data.bookingContainerSummary],
['Cargo / Goods Description', data.cargoDescription], ['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity], ['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`], ['Inventory Weight', `${data.weight.toLocaleString()} kg`],
['Booking Declared Weight', data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null],
['Warehouse', data.warehouse], ['Warehouse', data.warehouse],
['Yard', data.yard], ['Yard', data.yard],
['Zone', data.zone], ['Zone', data.zone],
@@ -2849,6 +3150,7 @@ export class WarehouseInventoryService {
['Inspection Status', data.inspectionStatus], ['Inspection Status', data.inspectionStatus],
['Release Order', data.releaseOrderReference], ['Release Order', data.releaseOrderReference],
['Release Date', fmt(data.releaseDate)], ['Release Date', fmt(data.releaseDate)],
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
]; ];
const approval = data.customerApproval; const approval = data.customerApproval;
@@ -2898,7 +3200,8 @@ export class WarehouseInventoryService {
</div> </div>
<div class="rule"></div> <div class="rule"></div>
<div class="notice"> <div class="notice">
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed. This handover document is separate from the warehouse Exit Paper. It records the booking, route, cargo, container,
inspection, release, and customer approval details for the goods being handed to the customer.
</div> </div>
<div class="section-title">Handover Particulars</div> <div class="section-title">Handover Particulars</div>
<table> <table>
@@ -2911,7 +3214,9 @@ export class WarehouseInventoryService {
<tbody> <tbody>
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr> <tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr> <tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr> <tr><th>Booking Containers</th><td>${esc(data.bookingContainerSummary)}</td></tr>
<tr><th>Inventory Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
<tr><th>Booking Declared Weight</th><td>${esc(data.bookingDeclaredWeight ? `${data.bookingDeclaredWeight.toLocaleString()} kg` : null)}</td></tr>
</tbody> </tbody>
</table> </table>
<div class="section-title">Handover Clause</div> <div class="section-title">Handover Clause</div>
@@ -3156,6 +3461,21 @@ export class WarehouseInventoryService {
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
} }
private async generateReleaseReference(item: WarehouseInventory): Promise<string> {
let bookingReference = item.booking?.reference;
if (!bookingReference && item.bookingId) {
const [booking]: Array<{ reference: string | null }> = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
[item.bookingId],
);
bookingReference = booking?.reference ?? undefined;
}
if (bookingReference) {
return `REL-${String(bookingReference).replace(/^BK-?/i, '')}`;
}
return `REL-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${item.id.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
}
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null { private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
const hasExitInspection = const hasExitInspection =
Boolean(dto.truckPlateNumber?.trim()) || Boolean(dto.truckPlateNumber?.trim()) ||
@@ -3179,17 +3499,27 @@ export class WarehouseInventoryService {
if (!dto.driverName?.trim()) { if (!dto.driverName?.trim()) {
throw new BadRequestException('Driver name is required for exit inspection'); throw new BadRequestException('Driver name is required for exit inspection');
} }
if (dto.tareWeight === undefined || dto.grossWeight === undefined) { if (dto.tareWeight === undefined) {
throw new BadRequestException('Tare weight and gross weight are required for exit inspection'); throw new BadRequestException('Tare weight is required for truck arrival');
} }
const tareWeight = Number(dto.tareWeight); const tareWeight = Number(dto.tareWeight);
const grossWeight = Number(dto.grossWeight); const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight);
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3)); const computedNetWeight =
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight); grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3));
const submittedNetWeight =
dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight);
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) { if (grossWeight != null && !dto.gateOutTime) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); throw new BadRequestException('Gate out time is required for truck exit');
}
if (grossWeight != null && computedNetWeight != null && submittedNetWeight != null) {
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
}
}
if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) {
throw new BadRequestException('Gross weight is required for truck exit');
} }
const rows = [ const rows = [
@@ -3205,14 +3535,27 @@ export class WarehouseInventoryService {
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
`Tare Weight: ${tareWeight} kg`, `Tare Weight: ${tareWeight} kg`,
`Gross Weight: ${grossWeight} kg`, grossWeight == null ? null : `Gross Weight: ${grossWeight} kg`,
`Net Weight: ${computedNetWeight} kg`, computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} kg`,
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
]; ];
return rows.filter(Boolean).join('\n'); return rows.filter(Boolean).join('\n');
} }
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
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}`;
}
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
}
private extractExitInspectionNote(notes?: string | null): string | null { private extractExitInspectionNote(notes?: string | null): string | null {
if (!notes) return null; if (!notes) return null;
const marker = '[Exit Inspection]'; const marker = '[Exit Inspection]';
@@ -3221,6 +3564,40 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null; return notes.slice(index + marker.length).trim() || null;
} }
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
const withoutHandover = withoutExit.split(`\n\n${HANDOVER_DOCUMENT_MARKER}`)[0] ?? withoutExit;
return this.stripCustomerDeliveryApproval(withoutHandover)?.trim() || withoutHandover.trim() || null;
}
private buildHandoverDocumentNote(reference: string, generatedAt: Date): string {
return [
HANDOVER_DOCUMENT_MARKER,
`Handover Reference: ${reference}`,
`Generated At: ${generatedAt.toISOString()}`,
].join('\n');
}
private replaceHandoverDocumentNote(notes: string | null | undefined, handoverDocumentNote: string): string {
const trimmed = notes?.trim();
if (!trimmed) return handoverDocumentNote;
const index = trimmed.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) {
return `${trimmed}\n\n${handoverDocumentNote}`;
}
return [trimmed.slice(0, index).trim(), handoverDocumentNote].filter(Boolean).join('\n\n');
}
private extractHandoverDocumentLine(notes: string | null | undefined, label: string): string | null {
if (!notes) return null;
const index = notes.lastIndexOf(HANDOVER_DOCUMENT_MARKER);
if (index < 0) return null;
const section = notes.slice(index + HANDOVER_DOCUMENT_MARKER.length);
const match = section.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private buildReceiveNote(input: { private buildReceiveNote(input: {
grnNumber: string; grnNumber: string;
direction?: string | null; direction?: string | null;

View File

@@ -0,0 +1,142 @@
import 'reflect-metadata';
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module';
import { Booking } from '../modules/bookings/entities/booking.entity';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const BOOKING_REFS = [
'WH-EXP-RCV-001',
'WH-EXP-RCV-002',
'WH-EXP-RCV-003',
'WH-EXP-RCV-004',
'WH-EXP-RCV-005',
];
async function main() {
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'],
});
try {
const dataSource = app.get(DataSource);
const yardRepo = dataSource.getRepository(Yard);
const serviceTypeRepo = dataSource.getRepository(ServiceType);
const cargoTypeRepo = dataSource.getRepository(CargoType);
const containerTypeRepo = dataSource.getRepository(ContainerType);
const bookingRepo = dataSource.getRepository(Booking);
const bookingContainerRepo = dataSource.getRepository(BookingContainer);
const inventoryRepo = dataSource.getRepository(WarehouseInventory);
const originYard =
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
const destinationYard =
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
const serviceType =
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER', includesFirstMile: false, isActive: true } })) ??
(await serviceTypeRepo.findOne({ where: { includesFirstMile: false, isActive: true } }));
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
const containerType =
(await containerTypeRepo.findOne({ where: { code: '40FT', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { code: '40', isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { sizeFt: 40, isActive: true } })) ??
(await containerTypeRepo.findOne({ where: { isActive: true } }));
const missing = [
!originYard ? 'MOJO/Ethiopia origin yard' : '',
!destinationYard ? 'DJIB_PORT/Djibouti destination yard' : '',
!serviceType ? 'active service type without first mile' : '',
!containerType ? 'active container type' : '',
].filter(Boolean);
if (missing.length) {
throw new Error(`Cannot seed warehouse export receive-ready bookings, missing: ${missing.join(', ')}`);
}
let created = 0;
let skipped = 0;
const now = Date.now();
for (const [index, reference] of BOOKING_REFS.entries()) {
const existing = await bookingRepo.findOne({ where: { reference } });
if (existing) {
skipped += 1;
continue;
}
const containerQuantity = index === 4 ? 2 : 1;
const weightKg = 18_000 + index * 1_250 + (containerQuantity - 1) * 9_000;
const scheduledDate = new Date(now + index * 60 * 60_000);
const booking = await bookingRepo.save(
bookingRepo.create({
reference,
originYardId: originYard!.id,
destinationYardId: destinationYard!.id,
serviceTypeId: serviceType!.id,
status: 'PAID',
paymentStatus: 'PAID',
scheduledDate,
contractType: 'SPOT',
equipmentReturn: 'TERMINAL',
paymentCurrency: 'ETB',
totalAmount: 0,
isGovernment: false,
tradeDirection: 'EXPORT',
freightType: 'CONTAINER',
cargoTypeId: cargoType?.id ?? null,
cargoFreeText: cargoType ? null : `Warehouse export receive-ready cargo ${index + 1}`,
cargoTotalWeightVgm: weightKg,
schedulingStatus: 'NOT_SCHEDULED',
}),
);
await bookingContainerRepo.save(
bookingContainerRepo.create({
bookingId: booking.id,
containerTypeId: containerType!.id,
containerNumber: `EDRU${String(730100 + index).padStart(6, '0')}`,
containerSize: containerType!.sizeFt ? `${containerType!.sizeFt}ft` : containerType!.code,
quantity: containerQuantity,
hazardousQuantity: 0,
reeferQuantity: 0,
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
isOverweight: false,
}),
);
const inventory = await inventoryRepo.findOne({ where: { bookingId: booking.id } });
if (inventory) {
throw new Error(`Seed invariant failed: booking ${reference} unexpectedly has warehouse inventory`);
}
created += 1;
}
console.log(`Warehouse export receive-ready seed complete. Created ${created}, skipped ${skipped}.`);
console.log(`Booking refs: ${BOOKING_REFS.join(', ')}`);
console.log('Open Backoffice Warehouse > Receive for loading > Export / Receive to Warehouse.');
} finally {
await app.close();
}
}
main().catch((error) => {
console.error('Warehouse export receive-ready seed failed:', error);
process.exit(1);
});

View File

@@ -23,11 +23,20 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
); );
} }
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-'; const bookingReference = item?.booking?.reference ?? '-';
const handoverReference = item?.handoverDocumentReference ?? noteLineValue(item?.notes, 'Handover Reference');
const handoverDate = item?.handoverDocumentDate ?? noteLineValue(item?.notes, 'Generated At');
const inventorySummary = [ const inventorySummary = [
item?.status?.replace(/_/g, ' '), item?.status?.replace(/_/g, ' '),
item?.grnNumber ? `GRN ${item.grnNumber}` : null,
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null, item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
handoverReference ? `Handover ${handoverReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null, item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
] ]
.filter(Boolean) .filter(Boolean)
@@ -61,11 +70,13 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" /> <Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}> <SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} /> <DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="GRN" value={item.grnNumber ?? '-'} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} /> <DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} /> <DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} /> <DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} /> <DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} /> <DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Handover reference" value={handoverReference || '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} /> <DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} /> <DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} /> <DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
@@ -83,6 +94,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} /> <DetailRow label="Dispatched" value={formatDate(item.dispatchedAt)} />
<DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} /> <DetailRow label="Ready for pickup" value={formatDate(item.readyForPickupAt)} />
<DetailRow label="Released" value={formatDate(item.releaseDate)} /> <DetailRow label="Released" value={formatDate(item.releaseDate)} />
<DetailRow label="Handover generated" value={formatDate(handoverDate)} />
<DetailRow label="Delivered" value={formatDate(item.deliveredAt)} /> <DetailRow label="Delivered" value={formatDate(item.deliveredAt)} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} /> <DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
</SimpleGrid> </SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -79,6 +79,45 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void; onReceived?: () => void;
} }
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(inventoryId);
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!grnNumber}
loading={loading}
onClick={openDocument}
>
{grnNumber ?? 'No GRN'}
</Button>
);
}
interface Location { interface Location {
warehouseId: string; warehouseId: string;
yardId: string; yardId: string;
@@ -620,11 +659,13 @@ function EligibleTab({
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const { data: allRows = [], isLoading } = useQuery( const { data: allRows = [], isLoading } = useQuery(
api.warehouses.eligibleBookings.queryOptions({ enabled }), api.warehouses.eligibleBookings.queryOptions({
input: { direction },
enabled,
}),
); );
const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]);
const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions());
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
const requestFirstMile = useMutation({ const requestFirstMile = useMutation({
mutationFn: (reference: string) => firstMileService.accept(reference), mutationFn: (reference: string) => firstMileService.accept(reference),
onSuccess: () => { onSuccess: () => {
@@ -782,10 +823,24 @@ function EligibleTab({
return; return;
} }
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0),
0,
);
const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? {
...form,
unitCount: totalContainerQuantity,
}
: form;
setPendingReceiveIds(filteredIds); setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString()); setReceivedAt(new Date().toISOString());
setTruckForm(form); setTruckForm(normalizedForm);
setLockedTruckFields(lockedFields); setLockedTruckFields({
...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
});
setPackagingFreightType(nextPackagingFreightType); setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true); setTruckOpen(true);
}; };
@@ -798,18 +853,6 @@ function EligibleTab({
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
}; };
const loadPassedExport = async () => {
try {
const r = await loadPassed.mutateAsync(undefined);
toast({
title: `${r.loadedCount} loaded`,
description: r.skippedCount ? `${r.skippedCount} skipped — inspection not passed` : undefined,
});
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
}
};
return ( return (
<Stack gap="sm" mt="sm"> <Stack gap="sm" mt="sm">
@@ -828,18 +871,6 @@ function EligibleTab({
Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible Selected: <b>{selected.size}</b> / {statusFilteredRows.length} eligible
</Text> </Text>
<Group gap="xs"> <Group gap="xs">
{direction === 'EXPORT' && (
<Button
size="compact-sm"
variant="light"
color="teal"
leftSection={<Truck size={14} />}
loading={loadPassed.isPending}
onClick={loadPassedExport}
>
Load Passed Export Items
</Button>
)}
<Button <Button
size="compact-sm" size="compact-sm"
color={direction === 'EXPORT' ? 'edr-green' : undefined} color={direction === 'EXPORT' ? 'edr-green' : undefined}
@@ -849,7 +880,7 @@ function EligibleTab({
loading={bulkReceive.isPending} loading={bulkReceive.isPending}
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))} onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
> >
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'} {direction === 'EXPORT' ? 'Receive All for Loading' : 'Receive All to Warehouse'}
</Button> </Button>
<Button <Button
size="compact-sm" size="compact-sm"
@@ -858,7 +889,7 @@ function EligibleTab({
loading={bulkReceive.isPending} loading={bulkReceive.isPending}
onClick={() => openTruckReceive([...selected])} onClick={() => openTruckReceive([...selected])}
> >
Receive Selected {direction === 'EXPORT' ? 'Receive Selected for Loading' : 'Receive Selected'}
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -1000,7 +1031,7 @@ function EligibleTab({
loading={bulkReceive.isPending} loading={bulkReceive.isPending}
onClick={() => openTruckReceive([r.id])} onClick={() => openTruckReceive([r.id])}
> >
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'} {canReceive ? (direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse') : 'Await First Mile'}
</Button> </Button>
)} )}
</Table.Td> </Table.Td>
@@ -1015,7 +1046,7 @@ function EligibleTab({
<Modal <Modal
opened={truckOpen} opened={truckOpen}
onClose={() => setTruckOpen(false)} onClose={() => setTruckOpen(false)}
title="Receive to Warehouse" title={direction === 'EXPORT' ? 'Receive for Loading' : 'Receive to Warehouse'}
centered centered
size="lg" size="lg"
> >
@@ -1175,6 +1206,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/> />
</Table.Th> </Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th> <Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th> <Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
@@ -1201,7 +1233,13 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text> <Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text> <Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1322,6 +1360,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/> />
</Table.Th> </Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th> <Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th> <Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
@@ -1344,7 +1383,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text> <Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text> <Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1492,6 +1537,7 @@ function LoadedExportTab({
</Table.Th> </Table.Th>
)} )}
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th> <Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th> <Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
@@ -1516,7 +1562,13 @@ function LoadedExportTab({
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text> <Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text> <Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
@@ -1866,12 +1918,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId: row.bookingId, bookingId: row.bookingId,
quantity: 1, quantity: 1,
weight: Number(row.weight) || 0, weight: Number(row.weight) || 0,
grnNumber: row.grnNumber,
status: row.currentStatus, status: row.currentStatus,
arrivedAt: row.arrivalTime, arrivedAt: row.arrivalTime,
unloadedAt: row.arrivalTime, unloadedAt: row.arrivalTime,
inspectionStatus: row.inspectionStatus, inspectionStatus: row.inspectionStatus,
releaseDate: row.releaseDate, releaseDate: row.releaseDate,
releaseOrderReference: row.releaseOrderReference, releaseOrderReference: row.releaseOrderReference,
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt, deliveredAt: row.deliveredAt,
booking: row.bookingId booking: row.bookingId
? { ? {
@@ -1902,6 +1957,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
try { try {
const response = await warehouseService.downloadHandoverDocument(row.id); const response = await warehouseService.downloadHandoverDocument(row.id);
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow); openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
} catch (error) { } catch (error) {
pdfWindow?.close(); pdfWindow?.close();
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
@@ -1910,6 +1966,20 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
} }
}; };
const openReleaseDocument = async (row: ImportUnloadedItem) => {
setBusyId(row.id);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadReleaseDocument(row.id);
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return ( return (
<Stack gap="sm" mt="sm"> <Stack gap="sm" mt="sm">
<Group justify="space-between"> <Group justify="space-between">
@@ -1959,6 +2029,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</Table.Th> </Table.Th>
<Table.Th>Booking ID</Table.Th> <Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Customer ID</Table.Th> <Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
<Table.Th>Arrival Time</Table.Th> <Table.Th>Arrival Time</Table.Th>
@@ -1987,7 +2058,13 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text> <Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text> <Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td>
<Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text> <Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
@@ -2049,7 +2126,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
color="yellow" color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))} onClick={() => setReleaseItem(toInventoryItem(r))}
> >
Truck Arrival {r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button> </Button>
</> </>
)} )}
@@ -2064,6 +2141,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Dispatch Dispatch
</Button> </Button>
)} )}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
loading={busyId === r.id}
onClick={() => openReleaseDocument(r)}
>
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button <Button
size="compact-xs" size="compact-xs"
@@ -2082,7 +2171,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<FileText size={14} />} leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)} onClick={() => openHandoverDocument(r)}
> >
Handover {r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button> </Button>
)} )}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}> <Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -2398,7 +2487,12 @@ function ExportWarehouseTabs({
onChanged?: () => void; onChanged?: () => void;
}) { }) {
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue'); const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled })); const { data: eligibleRows = [] } = useQuery(
api.warehouses.eligibleBookings.queryOptions({
input: { direction: 'EXPORT' },
enabled,
}),
);
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled })); const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled })); const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled })); const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, 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 } from '@tanstack/react-query';
@@ -46,6 +46,77 @@ const toIsoDateTime = (value: string) => {
return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}; };
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const offsetMs = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
};
const generateReleaseReference = (item: WarehouseInventoryItem | null) => {
const bookingReference = item?.booking?.reference;
if (bookingReference) return `REL-${bookingReference.replace(/^BK-?/i, '')}`;
if (item?.bookingId) return `REL-${item.bookingId.replace(/-/g, '').slice(0, 8).toUpperCase()}`;
return '';
};
const lineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
};
const splitContainerNumbers = (value: string | null | undefined) =>
(value ?? '')
.split(/[,;\n]+/)
.map((number) => number.trim())
.filter(Boolean);
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
return Boolean(item?.containerId || containerCount > 0 || freightType === 'CONTAINER');
};
const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedContainerNumber: string) => {
const savedNumbers = splitContainerNumbers(savedContainerNumber);
const itemNumbers = splitContainerNumbers(getItemContainerNumber(item));
const sourceNumbers = savedNumbers.length ? savedNumbers : itemNumbers;
const quantityCount = isContainerInventory(item, sourceNumbers.length) ? Number(item?.quantity ?? 0) : 0;
const expectedCount = Math.max(1, sourceNumbers.length, quantityCount);
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
};
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) { export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
@@ -56,7 +127,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const [driverLicense, setDriverLicense] = useState(''); const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState(''); const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState(''); const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState(''); const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
const [gateInTime, setGateInTime] = useState(''); const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>(''); const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>(''); const [grossWeight, setGrossWeight] = useState<number | ''>('');
@@ -66,26 +137,32 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => { useEffect(() => {
if (opened) { if (opened) {
setReference(item?.releaseOrderReference ?? ''); const inspection = parseInspectionNote(item?.notes);
setTruckPlateNumber(''); setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTrailerPlateNumber(''); setTruckPlateNumber(inspection.truckPlateNumber);
setDriverName(''); setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverLicense(''); setDriverName(inspection.driverName);
setDriverPhone(''); setDriverLicense(inspection.driverLicense);
setTruckType(''); setDriverPhone(inspection.driverPhone);
setContainerNumber(''); setTruckType(inspection.truckType);
setGateInTime(''); setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
setTareWeight(''); setGateInTime(inspection.gateInTime);
setGrossWeight(''); setTareWeight(inspection.tareWeight);
setNetWeight(item?.weight != null ? Number(item.weight) : ''); setGrossWeight(inspection.grossWeight);
setGateOutTime(''); setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
} }
}, [opened, item]); }, [opened, item]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight = const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch = const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001; computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
const handleSubmit = async () => { const handleSubmit = async () => {
if (!item) return; if (!item) return;
@@ -93,11 +170,19 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return; return;
} }
if (tareWeight === '' || grossWeight === '') { if (!gateInTime || tareWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' }); toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
return; return;
} }
if (weightMismatch) { if (isExitStep && (!gateOutTime || grossWeight === '')) {
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
}
if (isExitStep && weightMismatch) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Weight mismatch', title: 'Weight mismatch',
@@ -105,7 +190,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
}); });
return; return;
} }
const pdfWindow = window.open('', '_blank'); const pdfWindow = isExitStep ? window.open('', '_blank') : null;
try { try {
const released = await releaseMutation.mutateAsync({ const released = await releaseMutation.mutateAsync({
id: item.id, id: item.id,
@@ -119,14 +204,22 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
driverLicense: driverLicense.trim() || undefined, driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined, driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined, truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined, containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
gateInTime: toIsoDateTime(gateInTime), gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight), tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight), grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight), netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: toIsoDateTime(gateOutTime), gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
}, },
}); });
if (!isExitStep) {
toast({
title: 'Truck arrival saved',
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
return;
}
setDownloading(true); setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id); const response = await warehouseService.downloadReleaseDocument(item.id);
const blob = response.data; const blob = response.data;
@@ -148,20 +241,27 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
}; };
return ( return (
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg"> <Modal opened={opened} onClose={onClose} title={title} centered size="lg">
<Stack gap="md"> <Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light"> <Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm"> {isExitStep ? (
Register the customer truck and driver at arrival, record tare weight, then record gross <Text size="sm">
weight at exit after loading. Gate clearance is blocked when recorded net weight does not Record the truck leaving time and gross weight. The system recorded net weight is locked,
equal gross weight minus tare weight. and the exit paper is generated only when it equals gross weight minus tare weight.
</Text> </Text>
) : (
<Text size="sm">
Register the customer truck and driver at arrival, then save gate in time and tare weight.
Reopen this form when the truck is leaving to complete the exit weighing.
</Text>
)}
</Alert> </Alert>
<TextInput <TextInput
label="Release document reference" label="Release document reference"
placeholder="e.g. REL-2026-001" placeholder="e.g. REL-2026-001"
value={reference} value={reference}
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
/> />
<Select <Select
label="Registered first / last-mile truck" label="Registered first / last-mile truck"
@@ -169,6 +269,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable searchable
clearable clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS} data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} value={REGISTERED_FIRST_LAST_MILE_TRUCKS.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 = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -182,35 +283,53 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required required
value={truckPlateNumber} value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)} onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/> />
<TextInput <TextInput
label="Trailer plate number" label="Trailer plate number"
value={trailerPlateNumber} value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)} onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked}
/> />
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} /> <TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} /> <TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} /> <TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} /> <TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} /> <Stack gap={6}>
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} /> <SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isEntranceLocked}
/>
))}
</SimpleGrid>
</Stack>
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group> </Group>
<Group grow> <Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} /> <NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} /> <NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} /> <NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}> <Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b> Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text> </Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} /> <TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
</Group> </Group>
{weightMismatch && ( {weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light"> <Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -225,7 +344,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
Cancel Cancel
</Button> </Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}> <Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Save Truck Arrival & View Exit Paper {isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -1,13 +1,17 @@
import { useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react'; import { ArrowRightLeft, ClipboardList, Coins, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { import {
getNextInventoryAction, getNextInventoryAction,
type InventoryAction, type InventoryAction,
type WarehouseInventoryItem, type WarehouseInventoryItem,
} from '@/types/warehouse'; } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges'; import { InventoryStatusBadge } from './badges';
import { formatDate, formatNumber, humanizeEnum } from './options'; import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
interface WarehouseInventoryTableProps { interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -46,6 +50,56 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green', deliver: 'green',
}; };
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() ?? '';
};
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (!item.grnNumber) {
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
return;
}
setLoading(true);
const pdfWindow = window.open('', '_blank');
try {
const response = await warehouseService.downloadGrnDocument(item.id);
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
} catch (error) {
pdfWindow?.close();
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
} finally {
setLoading(false);
}
};
return (
<Button
size="compact-xs"
variant="subtle"
color="teal"
leftSection={<FileText size={12} />}
disabled={!item.grnNumber}
loading={loading}
onClick={openDocument}
>
{item.grnNumber ?? 'No GRN'}
</Button>
);
}
export function WarehouseInventoryTable({ export function WarehouseInventoryTable({
items, items,
busyId, busyId,
@@ -90,6 +144,7 @@ export function WarehouseInventoryTable({
</Table.Th> </Table.Th>
)} )}
<Table.Th>Booking</Table.Th> <Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th> <Table.Th>Facility</Table.Th>
<Table.Th>Warehouse</Table.Th> <Table.Th>Warehouse</Table.Th>
<Table.Th>Yard</Table.Th> <Table.Th>Yard</Table.Th>
@@ -111,6 +166,7 @@ export function WarehouseInventoryTable({
item.inspectionStatus === 'PASSED' && item.inspectionStatus === 'PASSED' &&
Boolean(item.bookingId) && Boolean(item.bookingId) &&
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT'); (!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
return ( return (
<Table.Tr key={item.id}> <Table.Tr key={item.id}>
@@ -136,6 +192,9 @@ export function WarehouseInventoryTable({
</Text> </Text>
)} )}
</Table.Td> </Table.Td>
<Table.Td>
<GrnDocumentButton item={item} />
</Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td> <Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td> <Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
<Table.Td>{item.yard?.code ?? '-'}</Table.Td> <Table.Td>{item.yard?.code ?? '-'}</Table.Td>
@@ -170,7 +229,7 @@ export function WarehouseInventoryTable({
loading={busy} loading={busy}
onClick={() => onAdvance(item, nextAction)} onClick={() => onAdvance(item, nextAction)}
> >
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))} {nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button> </Button>
)} )}
{item.status === 'READY_FOR_PICKUP' && ( {item.status === 'READY_FOR_PICKUP' && (
@@ -224,7 +283,10 @@ export function WarehouseInventoryTable({
</Tooltip> </Tooltip>
)} )}
{onHandoverDocument && canGenerateHandover && ( {onHandoverDocument && canGenerateHandover && (
<Tooltip label="Generate customer handover document" withArrow> <Tooltip
label={handoverReference ? `View handover document ${handoverReference}` : 'Generate customer handover document'}
withArrow
>
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}> <ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
<FileText size={16} /> <FileText size={16} />
</ActionIcon> </ActionIcon>

View File

@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`, MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`, RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`, RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
GRN_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/grn-document`,
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`, HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`, DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
// Receive (Import/Export bulk) // Receive (Import/Export bulk)

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';
/** /**
* URL that streams an uploaded file through the API by its UUID. Routes the * URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -1,5 +1,6 @@
import { Button, Card } from '@mantine/core'; import { useState } from 'react';
import { PackageSearch } from 'lucide-react'; import { Button, Card, Group, Modal, Stack } from '@mantine/core';
import { PackageSearch, Truck } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
@@ -7,6 +8,7 @@ import { WarehouseFlowWorkbench } from '@/components/warehouses';
export default function ExportWarehouseFlowPage() { export default function ExportWarehouseFlowPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [receiveOpen, setReceiveOpen] = useState(false);
return ( return (
<PageContainer> <PageContainer>
@@ -14,15 +16,41 @@ export default function ExportWarehouseFlowPage() {
title="Export Operations" title="Export Operations"
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow." subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
action={ action={
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}> <Group gap="xs">
Import Operations <Button
</Button> fw={700}
leftSection={<Truck size={16} />}
onClick={() => setReceiveOpen(true)}
>
Receive for Loading
</Button>
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
Import Operations
</Button>
</Group>
} }
/> />
<Card> <Card>
<WarehouseFlowWorkbench direction="EXPORT" /> <WarehouseFlowWorkbench direction="EXPORT" />
</Card> </Card>
<Modal
opened={receiveOpen}
onClose={() => setReceiveOpen(false)}
title="Receive for loading"
centered
size="80rem"
>
<Stack gap="md">
<WarehouseFlowWorkbench enabled={receiveOpen} direction="EXPORT" />
<Group justify="flex-end">
<Button variant="default" onClick={() => setReceiveOpen(false)}>
Close
</Button>
</Group>
</Stack>
</Modal>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -656,11 +656,11 @@ export const api = {
({ filter }) => ["warehouse-inventory", "inquiry", filter], ({ filter }) => ["warehouse-inventory", "inquiry", filter],
), ),
eligibleBookings: endpoint<void, EligibleBooking[]>( eligibleBookings: endpoint<{ direction?: 'IMPORT' | 'EXPORT' } | void, EligibleBooking[]>(
"warehouse-inventory", "warehouse-inventory",
"eligible-bookings", "eligible-bookings",
() => warehouseService.eligibleBookings().then((r) => r.data), (input) => warehouseService.eligibleBookings(input?.direction).then((r) => r.data),
() => ["warehouse-inventory", "eligible-bookings"], (input) => ["warehouse-inventory", "eligible-bookings", input?.direction ?? "ALL"],
), ),
readyToLoadExport: endpoint<void, ReadyToLoadRow[]>( readyToLoadExport: endpoint<void, ReadyToLoadRow[]>(

View File

@@ -137,6 +137,10 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), { apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
responseType: 'blob', responseType: 'blob',
}), }),
downloadGrnDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.GRN_DOCUMENT(id), {
responseType: 'blob',
}),
downloadHandoverDocument: (id: string) => downloadHandoverDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob', responseType: 'blob',

View File

@@ -190,6 +190,7 @@ export interface WarehouseInventoryItem {
quantity: number; quantity: number;
weight: number; weight: number;
volume: number | null; volume: number | null;
grnNumber: string | null;
status: InventoryStatus; status: InventoryStatus;
inspectionStatus: string | null; inspectionStatus: string | null;
arrivedAt: string | null; arrivedAt: string | null;
@@ -203,6 +204,8 @@ export interface WarehouseInventoryItem {
readyForPickupAt: string | null; readyForPickupAt: string | null;
releaseDate: string | null; releaseDate: string | null;
releaseOrderReference: string | null; releaseOrderReference: string | null;
handoverDocumentReference?: string | null;
handoverDocumentDate?: string | null;
deliveredAt: string | null; deliveredAt: string | null;
notes: string | null; notes: string | null;
warehouse?: Warehouse | null; warehouse?: Warehouse | null;
@@ -472,6 +475,7 @@ export interface ReadyToLoadRow {
containerNumber: string | null; containerNumber: string | null;
cargoType: string | null; cargoType: string | null;
weight: number | null; weight: number | null;
grnNumber: string | null;
origin: string | null; origin: string | null;
destination: string | null; destination: string | null;
inspectionStatus: string | null; inspectionStatus: string | null;
@@ -564,6 +568,7 @@ export interface ImportUnloadedItem {
containerNumber: string | null; containerNumber: string | null;
cargoType: string | null; cargoType: string | null;
weight: number | null; weight: number | null;
grnNumber: string | null;
trainSchedule: string | null; trainSchedule: string | null;
inspectionStatus: string | null; inspectionStatus: string | null;
pickupOption: string; pickupOption: string;
@@ -571,6 +576,8 @@ export interface ImportUnloadedItem {
currentStatus: string; currentStatus: string;
releaseDate: string | null; releaseDate: string | null;
releaseOrderReference: string | null; releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null; deliveredAt: string | null;
} }

View File

@@ -1,5 +1,5 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
// export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';
/** /**
* URL that streams an uploaded file through the API by its UUID. Routes the * URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -9,6 +9,7 @@ import {
ContractSignButton, ContractSignButton,
bookingIsSignable, bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton"; } from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
interface BookingRowProps { interface BookingRowProps {
booking: any; booking: any;
@@ -35,6 +36,7 @@ export const BookingRow = memo(function BookingRow({
// Contract ready for signature → "View & sign" jumps straight to the // Contract ready for signature → "View & sign" jumps straight to the
// full-page contract viewer where the signature flow lives. // full-page contract viewer where the signature flow lives.
const canSign = bookingIsSignable(booking); const canSign = bookingIsSignable(booking);
const canApproveDelivery = booking.status === "COMPLETED";
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—"; const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest = const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—"; booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -99,6 +101,12 @@ export const BookingRow = memo(function BookingRow({
<PayNowButton booking={booking} size="sm" /> <PayNowButton booking={booking} size="sm" />
) : canSign ? ( ) : canSign ? (
<ContractSignButton booking={booking} size="sm" /> <ContractSignButton booking={booking} size="sm" />
) : canApproveDelivery ? (
<ApproveDeliveryButton
bookingId={booking.id}
size="sm"
stopPropagation
/>
) : hasInlineAction ? ( ) : hasInlineAction ? (
<BookingActionButton booking={booking} size="sm" /> <BookingActionButton booking={booking} size="sm" />
) : ( ) : (

View File

@@ -11,6 +11,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
import { ActivityCard } from "./components/ActivityCard"; import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard"; import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard"; import { ContainersCard } from "./components/ContainersCard";
@@ -72,6 +73,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
(isGeneralContract (isGeneralContract
? status === "FULLY_EXECUTED" ? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH"); : status === "SELECTED_FOR_BATCH");
const canApproveDelivery = status === "COMPLETED";
const showCountdown = canPay && !!booking.paymentDeadline; const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED"; const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
@@ -93,14 +95,20 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<PageHeader <PageHeader
booking={booking} booking={booking}
actions={ actions={
canPay && (canApproveDelivery || (canPay && !showCountdown)) && (
!showCountdown && ( <Group gap={8} wrap="nowrap">
<HeaderButton {canApproveDelivery && (
green <ApproveDeliveryButton bookingId={booking.id} />
icon={<CreditCard size={16} />} )}
label="Pay now" {canPay && !showCountdown && (
onClick={() => setPayModalOpen(true)} <HeaderButton
/> green
icon={<CreditCard size={16} />}
label="Pay now"
onClick={() => setPayModalOpen(true)}
/>
)}
</Group>
) )
} }
menuActions={{ menuActions={{
@@ -248,4 +256,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
{viewer} {viewer}
</PageShell> </PageShell>
); );
} }

View File

@@ -0,0 +1,73 @@
import { Button, type ButtonProps } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2 } from "lucide-react";
import type { MouseEvent } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
type ApproveDeliveryButtonProps = ButtonProps & {
bookingId: string;
stopPropagation?: boolean;
onApproved?: () => void;
};
const errorMessage = (error: unknown) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : "Could not approve delivery";
};
export function ApproveDeliveryButton({
bookingId,
stopPropagation,
onApproved,
size = "sm",
variant = "filled",
...props
}: ApproveDeliveryButtonProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
...api.bookings.approveDelivery.mutationOptions(),
onSuccess: async () => {
toast.success("Delivery approved and handover signed");
await Promise.all([
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
queryClient.invalidateQueries({ queryKey: ["companies", "getDashboard"] }),
]);
onApproved?.();
},
onError: (error) => {
const message = errorMessage(error);
toast.error(message);
if (message.toLowerCase().includes("save your signature")) {
navigate("/signature");
}
},
});
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
if (stopPropagation) event.stopPropagation();
mutation.mutate({ id: bookingId });
};
return (
<Button
{...props}
size={size}
variant={variant}
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={mutation.isPending}
onClick={handleClick}
>
Approve delivery
</Button>
);
}

View File

@@ -8,6 +8,7 @@ import type {
} from "@/types/fileUploadSettings"; } from "@/types/fileUploadSettings";
import { import {
bookingsService, bookingsService,
type ApproveDeliveryResponse,
BookingListFilter, BookingListFilter,
CreateBookingPayload, CreateBookingPayload,
GeneratePriceResponse, GeneratePriceResponse,
@@ -297,6 +298,12 @@ export const api = {
({ orderId }) => bookingsService.checkPayment(orderId), ({ orderId }) => bookingsService.checkPayment(orderId),
), ),
approveDelivery: endpoint<{ id: string }, ApproveDeliveryResponse>(
"bookings",
"approveDelivery",
({ id }) => bookingsService.approveDelivery(id),
),
getBookableSchedules: endpoint< getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string }, { originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[] Freight.BookableScheduleItem[]