Truck Assign by customer plus Handover signature on portal

This commit is contained in:
hagiye
2026-07-02 12:32:18 +03:00
parent 129448e437
commit ad9f9de7a8
19 changed files with 1149 additions and 49 deletions

View File

@@ -91,9 +91,17 @@ export class BookingsService {
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
if (booking.lastMileDeliveryAddress?.trim()) {
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when last mile delivery is not selected',
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.customerTruckAssignedAt) {

View File

@@ -35,6 +35,7 @@ export interface ImportTrainItemRow {
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
freightType: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
@@ -274,6 +275,7 @@ export class SchedulingReadFacade {
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons",
b.freight_type AS "freightType",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",

View File

@@ -139,8 +139,18 @@ export class WarehouseInventoryController {
@Post('import/auto-unload-arrived-bookings')
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
autoUnloadArrivedBookings(@Body() dto: {
scheduleId: string;
warehouseId?: string;
performedBy?: string;
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
}) {
return this.inventoryService.autoUnloadArrivedBookings(
dto.scheduleId,
dto.performedBy,
dto.warehouseId,
dto.assignments,
);
}
@Get('import/unloaded-queue')

View File

@@ -180,6 +180,10 @@ interface LocationRef {
zoneId: string;
}
interface BookingUnloadLocation extends LocationRef {
bookingId: string;
}
interface LocationNode {
capacityWeight?: number | null;
capacityContainers?: number | null;
@@ -514,8 +518,9 @@ export class WarehouseInventoryService {
}));
}
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
const params = warehouseId ? [warehouseId] : [];
const [row]: DefaultLocation[] = await this.dataSource.query(
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
yard.id AS "yardId", zone.id AS "zoneId"
@@ -523,8 +528,10 @@ export class WarehouseInventoryService {
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
WHERE wh.deleted_at IS NULL
${warehouseId ? 'AND wh.id = $1' : ''}
ORDER BY wh.created_at ASC
LIMIT 1`,
LIMIT 1`,
params,
);
return row ?? null;
}
@@ -602,6 +609,7 @@ export class WarehouseInventoryService {
dto.warehouseId && dto.yardId && dto.zoneId
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
: null;
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
if (!location) location = await this.pickDefaultLocation();
if (!location) {
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
@@ -1185,6 +1193,8 @@ export class WarehouseInventoryService {
async autoUnloadArrivedBookings(
scheduleId: string,
performedBy?: string,
warehouseId?: string,
assignments: BookingUnloadLocation[] = [],
): Promise<AutoUnloadArrivedResult> {
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
@@ -1231,7 +1241,21 @@ export class WarehouseInventoryService {
[scheduleId],
);
const fallback = await this.pickDefaultLocation();
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
if (warehouseId && !requestedLocation) {
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
}
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
const assignmentByBooking = new Map(
assignments.map((assignment) => [
assignment.bookingId,
{
warehouseId: assignment.warehouseId,
yardId: assignment.yardId,
zoneId: assignment.zoneId,
} satisfies LocationRef,
]),
);
const now = new Date();
for (const booking of bookings) {
@@ -1251,6 +1275,8 @@ export class WarehouseInventoryService {
try {
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
const unloadLocation = assignedLocation ?? requestedLocation;
// Already unloaded or further along — leave it (do not regress the lifecycle).
if (existing && existing.status !== 'RECEIVED') {
@@ -1260,6 +1286,13 @@ export class WarehouseInventoryService {
if (existing) {
await this.inventoryRepository.update(existing.id, {
...(unloadLocation
? {
warehouseId: unloadLocation.warehouseId,
yardId: unloadLocation.yardId,
zoneId: unloadLocation.zoneId,
}
: {}),
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
@@ -1267,7 +1300,7 @@ export class WarehouseInventoryService {
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: existing.id,
warehouseId: existing.warehouseId,
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
@@ -1282,7 +1315,7 @@ export class WarehouseInventoryService {
tradeDirection: booking.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? fallback;
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
if (!location) {
fail('No warehouse/yard/zone configured');
continue;
@@ -2003,8 +2036,13 @@ export class WarehouseInventoryService {
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 reference = isTruckLeaving
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
: dto.reference?.trim() || (await this.generateReleaseReference(item));
const exitInspectionDto = isTruckLeaving
? this.preserveTruckArrivalForExit(dto, item.notes)
: dto;
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
@@ -3629,6 +3667,24 @@ export class WarehouseInventoryService {
return rows.filter(Boolean).join('\n');
}
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
const inspection = this.extractExitInspectionNote(notes);
if (!inspection) return dto;
return {
...dto,
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
};
}
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
const trimmed = notes?.trim();
if (!exitInspectionNote) return trimmed || null;
@@ -3650,6 +3706,18 @@ export class WarehouseInventoryService {
return notes.slice(index + marker.length).trim() || null;
}
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
return match?.[1]?.trim() || null;
}
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
private extractReceiveSummary(notes?: string | null): string | null {
if (!notes?.trim()) return null;
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;