mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
Invoice and clearance seal approval
This commit is contained in:
@@ -22,6 +22,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
tin?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -191,6 +192,9 @@ export interface EligibleBookingRow {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -205,6 +209,10 @@ export interface EligibleBookingRow {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -289,6 +297,8 @@ export interface ImportUnloadedRow {
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
private readonly logger = new Logger(WarehouseInventoryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
@@ -301,6 +311,7 @@ export class WarehouseInventoryService {
|
||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -644,6 +655,10 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "reference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
@@ -659,7 +674,14 @@ export class WarehouseInventoryService {
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -675,6 +697,7 @@ export class WarehouseInventoryService {
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
@@ -695,7 +718,6 @@ export class WarehouseInventoryService {
|
||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
@@ -711,23 +733,40 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
||||
`SELECT b.reference AS "reference",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus"
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
@@ -758,11 +797,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const truckEntrance = this.mergeSystemTruckEntrance(dto.truckEntrance, booking);
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -783,12 +824,21 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance.customerPhone,
|
||||
ownerName: truckEntrance.ownerName,
|
||||
bookingReference: truckEntrance.edrDigitalBookingId,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
}
|
||||
@@ -1483,7 +1533,6 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
@@ -1495,6 +1544,10 @@ export class WarehouseInventoryService {
|
||||
if (dto.bookingId) {
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
}
|
||||
const truckEntrance = dto.bookingId
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, await this.getBookingTruckEntranceSource(manager, dto.bookingId))
|
||||
: dto.truckEntrance;
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||
@@ -1505,7 +1558,7 @@ export class WarehouseInventoryService {
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -1529,14 +1582,23 @@ export class WarehouseInventoryService {
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance.customerPhone,
|
||||
ownerName: truckEntrance.ownerName,
|
||||
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
|
||||
grnNumber,
|
||||
direction: bookingDirection,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
@@ -2522,6 +2584,111 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSystemTruckEntrance(
|
||||
submitted: TruckEntranceDto,
|
||||
booking: {
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
...submitted,
|
||||
ownerName: booking.customer?.trim() || submitted.ownerName,
|
||||
edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
|
||||
tin: booking.customerTin?.trim() || submitted.tin,
|
||||
customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||||
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
|
||||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||||
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
|
||||
};
|
||||
}
|
||||
|
||||
private async getBookingTruckEntranceSource(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
): Promise<{
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
}> {
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.reference AS "reference",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
return booking ?? {};
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
bookingReference?: string | null;
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
}): Promise<void> {
|
||||
const phone = params.phone?.trim();
|
||||
if (!phone) return;
|
||||
|
||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||
const bookingReference = params.bookingReference?.trim();
|
||||
const message =
|
||||
`Dear ${ownerName}, your cargo has been received by EDR warehouse. ` +
|
||||
(bookingReference ? `Booking: ${bookingReference}. ` : '') +
|
||||
`GRN: ${params.grnNumber}. ` +
|
||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||
`Thank you.`;
|
||||
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (error) {
|
||||
// Receiving inventory must not be rolled back because an SMS provider is unavailable.
|
||||
this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
@@ -2542,6 +2709,7 @@ export class WarehouseInventoryService {
|
||||
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck.tin ? `TIN: ${truck.tin}` : null,
|
||||
truck.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
|
||||
@@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService {
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 86).slice(0, 52);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const y = 770 - index * 12;
|
||||
const isTitle = index < 2 || /clearance|release order/i.test(line);
|
||||
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
|
||||
const font = isTitle ? 'F2' : 'F1';
|
||||
return this.textOp(line, 48, y, size, font);
|
||||
})
|
||||
.join('\n');
|
||||
const doc = this.extractReleaseDocument(html);
|
||||
const body: string[] = [
|
||||
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
||||
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
||||
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
||||
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
|
||||
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
|
||||
...this.wrapLines(doc.notice, 68)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
||||
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
||||
];
|
||||
|
||||
let y = 586;
|
||||
const rowHeight = 20;
|
||||
for (const [label, value] of doc.rows.slice(0, 14)) {
|
||||
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
|
||||
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
|
||||
y -= rowHeight;
|
||||
}
|
||||
|
||||
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
|
||||
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
|
||||
body.push(
|
||||
...this.wrapLines(doc.clause, 92)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
|
||||
);
|
||||
|
||||
const stream = [
|
||||
this.lineOp(48, 752, 548, 752),
|
||||
body,
|
||||
this.circularSealOps(184, 154),
|
||||
this.lineOp(48, 92, 278, 92, '0 0 0'),
|
||||
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
|
||||
this.lineOp(326, 92, 548, 92, '0 0 0'),
|
||||
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
|
||||
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
|
||||
...body,
|
||||
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
||||
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
||||
this.circularSealOps(286, 62, 38),
|
||||
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
||||
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
||||
].join('\n');
|
||||
|
||||
const objects = [
|
||||
@@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService {
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private extractReleaseDocument(html: string): {
|
||||
reference: string;
|
||||
issuedAt: string;
|
||||
notice: string;
|
||||
clause: string;
|
||||
rows: Array<[string, string]>;
|
||||
} {
|
||||
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
|
||||
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
|
||||
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
|
||||
const notice = textFromHtml(
|
||||
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
|
||||
'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.',
|
||||
);
|
||||
const clause = textFromHtml(
|
||||
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
|
||||
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
|
||||
);
|
||||
const rows: Array<[string, string]> = [];
|
||||
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
|
||||
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
|
||||
}
|
||||
return { reference, issuedAt, notice, clause, rows };
|
||||
}
|
||||
|
||||
private htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
@@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService {
|
||||
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
|
||||
}
|
||||
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
|
||||
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
|
||||
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number): string {
|
||||
private rectOp(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fillColor = '1 1 1',
|
||||
strokeColor = '0.08 0.32 0.18',
|
||||
lineWidth = 0.8,
|
||||
): string {
|
||||
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number, radius = 51): string {
|
||||
return [
|
||||
'q',
|
||||
'0.08 0.32 0.18 RG',
|
||||
'0.08 0.32 0.18 rg',
|
||||
'2.2 w',
|
||||
this.circlePath(cx, cy, 51),
|
||||
this.circlePath(cx, cy, radius),
|
||||
'S',
|
||||
'0.8 w',
|
||||
this.circlePath(cx, cy, 41),
|
||||
this.circlePath(cx, cy, radius - 10),
|
||||
'S',
|
||||
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
@@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
|
||||
@@ -58,6 +58,7 @@ interface TruckEntranceFormState {
|
||||
consigneeDetails: string;
|
||||
edrDigitalBookingId: string;
|
||||
tin: string;
|
||||
customerPhone: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
assignedEquipmentNumber: string;
|
||||
@@ -85,11 +86,21 @@ interface TruckEntranceFormState {
|
||||
warehouseManagerName: string;
|
||||
}
|
||||
|
||||
interface LockedTruckEntranceFields {
|
||||
ownerName?: boolean;
|
||||
tin?: boolean;
|
||||
edrDigitalBookingId?: boolean;
|
||||
customerPhone?: boolean;
|
||||
}
|
||||
|
||||
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
|
||||
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
ownerName: '',
|
||||
consigneeDetails: '',
|
||||
edrDigitalBookingId: '',
|
||||
tin: '',
|
||||
customerPhone: '',
|
||||
truckPlateNumber: '',
|
||||
trailerPlateNumber: '',
|
||||
assignedEquipmentNumber: '',
|
||||
@@ -122,6 +133,7 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
||||
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
||||
tin: form.tin.trim() || undefined,
|
||||
customerPhone: form.customerPhone.trim() || undefined,
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
@@ -149,13 +161,106 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
||||
return unique.length === 1 ? unique[0] : '';
|
||||
};
|
||||
|
||||
const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
form: TruckEntranceFormState;
|
||||
lockedFields: LockedTruckEntranceFields;
|
||||
packagingFreightType: PackagingFreightType;
|
||||
} => {
|
||||
const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
||||
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
|
||||
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
|
||||
const edrDigitalBookingId =
|
||||
bookings.length === 1
|
||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
|
||||
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
|
||||
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
|
||||
const packagingFreightType =
|
||||
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
|
||||
? 'CONTAINER'
|
||||
: freightTypes.length === 1 && freightTypes[0] === 'BULK'
|
||||
? 'BULK'
|
||||
: 'MIXED';
|
||||
|
||||
return {
|
||||
form: {
|
||||
...emptyTruckEntrance(),
|
||||
ownerName,
|
||||
tin,
|
||||
customerPhone,
|
||||
edrDigitalBookingId,
|
||||
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
|
||||
driverName: firstMileBooking?.firstMileDriverName ?? '',
|
||||
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
|
||||
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
|
||||
truckType: firstMileBooking?.firstMileTruckType ?? '',
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
tin: Boolean(tin),
|
||||
edrDigitalBookingId: Boolean(edrDigitalBookingId),
|
||||
customerPhone: Boolean(customerPhone),
|
||||
},
|
||||
packagingFreightType,
|
||||
};
|
||||
};
|
||||
|
||||
const BULK_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'BAG', label: 'Bag' },
|
||||
{ value: 'SACK', label: 'Sack' },
|
||||
{ value: 'BALE', label: 'Bale' },
|
||||
{ value: 'CARTON', label: 'Carton' },
|
||||
{ value: 'CRATE', label: 'Crate' },
|
||||
{ value: 'DRUM', label: 'Drum' },
|
||||
{ value: 'BARREL', label: 'Barrel' },
|
||||
{ value: 'PALLET', label: 'Pallet' },
|
||||
{ value: 'LOOSE_BULK', label: 'Loose bulk' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
const CONTAINER_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'CONTAINER_20FT', label: '20 ft container' },
|
||||
{ value: 'CONTAINER_40FT', label: '40 ft container' },
|
||||
{ value: 'CONTAINER_45FT', label: '45 ft container' },
|
||||
{ value: 'REEFER_CONTAINER', label: 'Reefer container' },
|
||||
{ value: 'TANK_CONTAINER', label: 'Tank container' },
|
||||
{ value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' },
|
||||
{ value: 'OPEN_TOP_CONTAINER', label: 'Open top container' },
|
||||
{ value: 'OTHER_CONTAINER', label: 'Other container' },
|
||||
];
|
||||
|
||||
const packagingOptionsFor = (freightType: PackagingFreightType) =>
|
||||
freightType === 'CONTAINER'
|
||||
? CONTAINER_PACKAGING_TYPE_OPTIONS
|
||||
: freightType === 'BULK'
|
||||
? BULK_PACKAGING_TYPE_OPTIONS
|
||||
: [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS];
|
||||
|
||||
function TruckEntranceFields({
|
||||
value,
|
||||
onChange,
|
||||
lockedFields,
|
||||
packagingFreightType = 'MIXED',
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
lockedFields?: LockedTruckEntranceFields;
|
||||
packagingFreightType?: PackagingFreightType;
|
||||
}) {
|
||||
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||
const quantityLabel =
|
||||
packagingFreightType === 'CONTAINER'
|
||||
? 'Container quantity'
|
||||
: packagingFreightType === 'BULK'
|
||||
? 'Unit count'
|
||||
: 'Quantity';
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
||||
@@ -163,6 +268,7 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Owner's name"
|
||||
value={value.ownerName}
|
||||
readOnly={lockedFields?.ownerName}
|
||||
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -175,14 +281,22 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="EDR digital booking ID"
|
||||
value={value.edrDigitalBookingId}
|
||||
readOnly={lockedFields?.edrDigitalBookingId}
|
||||
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="TIN"
|
||||
value={value.tin}
|
||||
readOnly={lockedFields?.tin}
|
||||
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Customer phone"
|
||||
value={value.customerPhone}
|
||||
readOnly={lockedFields?.customerPhone}
|
||||
onChange={(e) => onChange({ ...value, customerPhone: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
||||
<Group grow>
|
||||
@@ -285,13 +399,16 @@ function TruckEntranceFields({
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Packaging type"
|
||||
data={packagingOptions}
|
||||
clearable
|
||||
searchable
|
||||
value={value.packagingType}
|
||||
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
|
||||
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Unit count"
|
||||
label={quantityLabel}
|
||||
min={0}
|
||||
value={value.unitCount}
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
@@ -466,6 +583,8 @@ function EligibleTab({
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||
|
||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||
const canReceiveBooking = (row: EligibleBooking) =>
|
||||
@@ -564,13 +683,14 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||
return;
|
||||
}
|
||||
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
|
||||
const selectedRows = filteredIds
|
||||
.map((id) => rows.find((item) => item.id === id))
|
||||
.filter(Boolean) as EligibleBooking[];
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setTruckForm({
|
||||
...emptyTruckEntrance(),
|
||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
||||
});
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
@@ -593,6 +713,8 @@ function EligibleTab({
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
@@ -805,7 +927,12 @@ function EligibleTab({
|
||||
<Text size="sm" c="dimmed">
|
||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
<TruckEntranceFields
|
||||
value={truckForm}
|
||||
onChange={setTruckForm}
|
||||
lockedFields={lockedTruckFields}
|
||||
packagingFreightType={packagingFreightType}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
Cancel
|
||||
|
||||
@@ -356,6 +356,9 @@ export interface EligibleBooking {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -370,6 +373,10 @@ export interface EligibleBooking {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -392,6 +399,7 @@ export interface TruckEntrancePayload {
|
||||
consigneeDetails?: string;
|
||||
edrDigitalBookingId?: string;
|
||||
tin?: string;
|
||||
customerPhone?: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber?: string;
|
||||
assignedEquipmentNumber?: string;
|
||||
|
||||
Reference in New Issue
Block a user