mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 02:00:56 +00:00
Merge pull request #835 from Tria-plc/intercity-load-unload
Truck assignment on Bulk and Container
This commit is contained in:
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { usesEdrMileService } from './mile-haulage.util';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract and copied onto the booking. EDR
|
||||
* haulage and a customer's own truck are alternatives, so this one answer gates
|
||||
* both sides — the customer-truck guard and the mile-queue guard.
|
||||
*/
|
||||
describe('usesEdrMileService', () => {
|
||||
const booking = (over: Partial<Parameters<typeof usesEdrMileService>[0]> = {}) => ({
|
||||
tradeDirection: 'IMPORT',
|
||||
firstMile: null,
|
||||
lastMile: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('an import that chose delivery uses EDR haulage', () => {
|
||||
expect(usesEdrMileService(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('an import that chose nothing does not', () => {
|
||||
expect(usesEdrMileService(booking())).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores the pickup address on an import — collection is the export leg', () => {
|
||||
expect(usesEdrMileService(booking({ firstMile: 'Modjo' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('an export that chose collection uses EDR haulage', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores the delivery address on an export — delivery is the import leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a domestic booking counts either leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only address as no choice', () => {
|
||||
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** The booking fields that decide who hauls the road legs. */
|
||||
export interface MileHaulageRow {
|
||||
tradeDirection: string | null;
|
||||
/** `first_mile_pickup_address` — set when the customer asked EDR to collect. */
|
||||
firstMile: string | null;
|
||||
/** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */
|
||||
lastMile: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the customer bought the EDR road leg that matters for their direction:
|
||||
* delivery at the end of an import, collection at the start of an export. A
|
||||
* DOMESTIC booking can use either, so either one counts.
|
||||
*
|
||||
* The address is the signal because it is the only per-booking record of the
|
||||
* choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be
|
||||
* used — every service type ships with both set to true, so reading them would
|
||||
* mean every booking uses EDR haulage and none could ever self-haul.
|
||||
*/
|
||||
export function usesEdrMileService(booking: MileHaulageRow): boolean {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMile?.trim());
|
||||
switch (booking.tradeDirection) {
|
||||
case 'IMPORT':
|
||||
return hasLastMile;
|
||||
case 'EXPORT':
|
||||
return hasFirstMile;
|
||||
default:
|
||||
return hasFirstMile || hasLastMile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR haulage and a customer's own truck are alternatives, never both. Whichever
|
||||
* side is being set up, it has to reject the other — a guard on only one side
|
||||
* lets the two paths open on the same booking, each unaware of the other.
|
||||
*/
|
||||
export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||
'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
* neither has no business in the first/last-mile queues at all.
|
||||
*/
|
||||
export const NO_MILE_SERVICE_MESSAGE =
|
||||
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';
|
||||
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
assertTruckLoad,
|
||||
remainingBulkTons,
|
||||
} from './truck-load.util';
|
||||
|
||||
/**
|
||||
* One physical rule, shared by customer self-haul and EDR last-mile. It used to
|
||||
* be written out three times (addTruck, updateTruck, departTruck) plus a fourth
|
||||
* in LastMileService.
|
||||
*/
|
||||
describe('assertTruckLoad', () => {
|
||||
const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'];
|
||||
|
||||
it('accepts two 20ft containers on one truck', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ABCD1234567', 'ABCD7654321'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['20ft', '20ft'],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a single 40ft container', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ABCD1234567'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['40ft'],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a 40ft sharing the truck — it fills the bed', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ABCD1234567', 'ABCD7654321'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['40ft', '20ft'],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects more than two containers', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['20ft', '20ft', '20ft'],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a container that is not on the booking', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ZZZZ9999999'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['20ft'],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a container already riding another truck', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['ABCD1234567'],
|
||||
bookingContainers: booking,
|
||||
sizes: ['20ft'],
|
||||
assignedElsewhere: ['ABCD1234567'],
|
||||
}),
|
||||
).toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('skips membership checks when the booking has no containers (bulk)', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('still caps the count when the booking has no containers', () => {
|
||||
expect(() =>
|
||||
assertTruckLoad({
|
||||
containers: ['A', 'B', 'C'],
|
||||
bookingContainers: [],
|
||||
sizes: [],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertBulkTonnageRemains', () => {
|
||||
it('allows another truck while tonnage is left', () => {
|
||||
expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a truck once the booking is fully hauled', () => {
|
||||
expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not cap a booking with no declared weight', () => {
|
||||
// Nothing to draw down against — capping here would block every truck.
|
||||
expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remainingBulkTons', () => {
|
||||
const dataSourceReturning = (totalTons: string, hauledTons: string) =>
|
||||
({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never;
|
||||
|
||||
it('counts trucks from both haulage paths against the declared weight', async () => {
|
||||
const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
totalTons: 100,
|
||||
hauledTons: 60,
|
||||
remainingTons: 40,
|
||||
complete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is complete once everything is hauled', async () => {
|
||||
const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1');
|
||||
|
||||
expect(result.remainingTons).toBe(0);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('never reports negative tonnage when trucks overshoot', async () => {
|
||||
const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1');
|
||||
|
||||
expect(result.remainingTons).toBe(0);
|
||||
expect(result.complete).toBe(true);
|
||||
});
|
||||
|
||||
it('is not complete for a booking with no declared weight', async () => {
|
||||
const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1');
|
||||
|
||||
expect(result.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertTruckCountWithinContainers', () => {
|
||||
it('allows one truck per container', () => {
|
||||
expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects more trucks than containers', () => {
|
||||
expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not cap a bulk booking, which has no container count', () => {
|
||||
expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
/** Two 20ft containers fit a truck bed; one 40ft fills it. */
|
||||
export const MAX_CONTAINERS_PER_TRUCK = 2;
|
||||
|
||||
/**
|
||||
* What one truck is being asked to carry, and the booking context to judge it
|
||||
* against. `sizes` are the container_size labels of `containers`, in any order —
|
||||
* only whether a 40ft is present matters.
|
||||
*/
|
||||
export interface TruckLoadCheck {
|
||||
containers: string[];
|
||||
/** Every container number on the booking. Empty means nothing to validate against. */
|
||||
bookingContainers: string[];
|
||||
sizes: string[];
|
||||
/** Containers already riding another truck on this booking. */
|
||||
assignedElsewhere?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The physical rule for loading one truck, shared by both haulage paths.
|
||||
*
|
||||
* A customer's own truck and an EDR last-mile truck obey the same physics, but
|
||||
* the rule was implemented twice — once in CustomerTruckService, once in
|
||||
* LastMileService — along with a byte-identical container-size query. Two copies
|
||||
* of one rule drift, and that is exactly how the self-haul guard ended up
|
||||
* enforced on one side only.
|
||||
*/
|
||||
export function assertTruckLoad({
|
||||
containers,
|
||||
bookingContainers,
|
||||
sizes,
|
||||
assignedElsewhere = [],
|
||||
}: TruckLoadCheck): void {
|
||||
if (containers.length > MAX_CONTAINERS_PER_TRUCK) {
|
||||
throw new BadRequestException(
|
||||
`A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`,
|
||||
);
|
||||
}
|
||||
|
||||
// With no container list on the booking there is nothing to check membership
|
||||
// against — bulk bookings take this path.
|
||||
if (!bookingContainers.length) return;
|
||||
|
||||
for (const number of containers) {
|
||||
if (!bookingContainers.includes(number)) {
|
||||
throw new BadRequestException(
|
||||
`Container ${number} is not one of this booking's containers`,
|
||||
);
|
||||
}
|
||||
if (assignedElsewhere.includes(number)) {
|
||||
throw new ConflictException(`Container ${number} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
|
||||
// A 40ft fills the bed, so it travels alone.
|
||||
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Never put more trucks on a booking than it has containers to fill them. */
|
||||
export function assertTruckCountWithinContainers(
|
||||
truckCount: number,
|
||||
bookingContainerCount: number,
|
||||
): void {
|
||||
if (bookingContainerCount > 0 && truckCount > bookingContainerCount) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a bulk booking is still to be hauled. Counts trucks from BOTH
|
||||
* haulage paths — a booking uses one or the other, and the rule ("trucks until
|
||||
* no tonnage is left") is the same either way, so a single sum keeps them from
|
||||
* disagreeing.
|
||||
*
|
||||
* Only departed trucks count: tonnage is known once the truck is weighed out.
|
||||
*/
|
||||
export async function remainingBulkTons(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> {
|
||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||
COALESCE((
|
||||
SELECT SUM(va.net_weight_tons)
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
WHERE lm.booking_id = b.id
|
||||
AND va.deleted_at IS NULL
|
||||
AND va.departed_at IS NOT NULL
|
||||
), 0)
|
||||
+ COALESCE((
|
||||
SELECT SUM(a.net_weight_tons)
|
||||
FROM freight.customer_truck_assignments a
|
||||
WHERE a.booking_id = b.id
|
||||
AND a.deleted_at IS NULL
|
||||
AND a.departed_at IS NOT NULL
|
||||
), 0) AS "hauledTons"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const totalTons = Number(row?.totalTons ?? 0);
|
||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||
}
|
||||
|
||||
/** A fully-hauled bulk booking has nothing left for another truck to carry. */
|
||||
export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void {
|
||||
if (totalTons > 0 && remainingTons <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* container_size labels for the given container numbers on a booking. Shared so
|
||||
* the two haulage paths read sizes the same way.
|
||||
*/
|
||||
export async function bookingContainerSizes(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
numbers: string[],
|
||||
): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((row) => (row.size ?? '').trim());
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck exit weights for customer self-haul, mirroring what
|
||||
* `last_mile_vehicle_assignments` already carries for EDR trucks.
|
||||
*
|
||||
* A bulk booking is hauled away truck by truck until no tonnage is left, and the
|
||||
* EDR side enforces that by summing `net_weight_tons` of departed trucks. The
|
||||
* customer side had no net and no tare — only `gross_weight_kg`, which nothing
|
||||
* in the live flow ever wrote (the release flow updated the EDR table only). So
|
||||
* a self-haul bulk booking could take unlimited trucks: hauled tonnage always
|
||||
* summed to zero.
|
||||
*
|
||||
* `gross_weight_kg` is left alone but note it holds TONNES despite its name —
|
||||
* the weighing UI is in tonnes throughout. The new columns are named for the
|
||||
* unit they actually hold.
|
||||
*/
|
||||
export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface {
|
||||
name = 'AddCustomerTruckExitWeights2400000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_assignments
|
||||
ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL
|
||||
`);
|
||||
|
||||
// Departed trucks are what the drawdown sums, so it reads this index.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed"
|
||||
ON freight.customer_truck_assignments (booking_id, departed_at)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.customer_truck_assignments
|
||||
DROP COLUMN IF EXISTS tare_weight_tons,
|
||||
DROP COLUMN IF EXISTS net_weight_tons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,17 @@ import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
assertTruckLoad,
|
||||
bookingContainerSizes,
|
||||
remainingBulkTons,
|
||||
} from '../../common/truck-load.util';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
@@ -67,39 +78,27 @@ export class CustomerTruckService {
|
||||
if (!isBulk && requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
|
||||
// Bulk is capped by tonnage, not container count: trucks may be added until
|
||||
// the booking's declared weight has been hauled away. Container bookings are
|
||||
// capped below by #trucks <= #containers.
|
||||
if (isBulk) {
|
||||
const { totalTons, remainingTons } = await remainingBulkTons(this.dataSource, bookingId);
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
}
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (alreadyAssigned.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
// Size cap: a 40ft container fills the truck.
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
assertTruckCountWithinContainers(existingTrucks + 1, bookingNumbers.length);
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: bookingNumbers,
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
assignedElsewhere: await this.assignedContainerNumbers(bookingId),
|
||||
});
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -191,28 +190,13 @@ export class CustomerTruckService {
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (assignedElsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
@@ -340,27 +324,12 @@ export class CustomerTruckService {
|
||||
}
|
||||
// Capacity is size-based: a truck carries at most 2 containers, and a 40ft
|
||||
// container fills the truck (max 1) — mirror the addTruck/updateTruck rule.
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (elsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — load only 1 container onto this truck',
|
||||
);
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: requested,
|
||||
bookingContainers: await this.bookingContainerNumbers(bookingId),
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, requested),
|
||||
assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId),
|
||||
});
|
||||
|
||||
const grossTons = await this.vgmTonsForContainers(bookingId, requested);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -534,18 +503,11 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMile?.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 first/last mile delivery is not selected',
|
||||
);
|
||||
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||
// with a customer truck and an EDR leg at once.
|
||||
if (usesEdrMileService(booking)) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
@@ -614,18 +576,4 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ export class CustomerTruckAssignment extends BaseEntity {
|
||||
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
grossWeightKg?: number | null;
|
||||
|
||||
/** Empty truck weight at the gate, in tonnes. Null until the truck departs. */
|
||||
@Column({ name: 'tare_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
tareWeightTons?: number | null;
|
||||
|
||||
/**
|
||||
* Cargo actually taken (gross − tare), in tonnes. Drives the bulk drawdown:
|
||||
* a bulk booking is hauled until the sum of this across departed trucks
|
||||
* reaches its declared VGM. Mirrors last_mile_vehicle_assignments.
|
||||
*/
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -333,10 +333,12 @@ export class FirstMileService {
|
||||
firstMilePickupAddress?: string | null;
|
||||
serviceType?: { includesFirstMile?: boolean | null } | null;
|
||||
}): boolean {
|
||||
// The pickup address is the only record of what the contract chose.
|
||||
// `serviceType.includesFirstMile` used to satisfy this too, but every
|
||||
// service type ships with it set to true, so the OR made the address check
|
||||
// dead and admitted every paid export booking into the queue.
|
||||
return Boolean(
|
||||
booking.tradeDirection === 'EXPORT' &&
|
||||
(booking.firstMilePickupAddress?.trim() ||
|
||||
booking.serviceType?.includesFirstMile),
|
||||
booking.tradeDirection === 'EXPORT' && booking.firstMilePickupAddress?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { LastMileService } from './last-mile.service';
|
||||
|
||||
/**
|
||||
* A booking reaches the last-mile queue only if its contract bought EDR
|
||||
* delivery, and never if the customer is hauling it themselves. Creation used
|
||||
* to check payment alone, so any paid booking could be accepted — which put a
|
||||
* self-haul booking and an EDR leg on the same shipment at once.
|
||||
*/
|
||||
type BookingRow = { tradeDirection: string; firstMile: string | null; lastMile: string | null };
|
||||
|
||||
function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean }) {
|
||||
const booking = opts.booking ?? {
|
||||
tradeDirection: 'IMPORT',
|
||||
firstMile: null,
|
||||
lastMile: 'Bole, Addis Ababa',
|
||||
};
|
||||
|
||||
const query = jest.fn((sql: string) => {
|
||||
if (sql.includes('customer_truck_assignments')) {
|
||||
return Promise.resolve(opts.hasCustomerTruck ? [{ '?column?': 1 }] : []);
|
||||
}
|
||||
if (sql.includes('FROM freight.bookings')) {
|
||||
return Promise.resolve([booking]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
const lastMileRepository = {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
|
||||
};
|
||||
|
||||
const service = new LastMileService(
|
||||
lastMileRepository as never,
|
||||
{} as never, // bookingsRepository
|
||||
{ setAvailability: jest.fn() } as never, // vehiclesService
|
||||
{} as never, // driversService
|
||||
{} as never, // smsClient
|
||||
{ query } as unknown as DataSource,
|
||||
{ record: jest.fn() } as never, // history
|
||||
{} as never, // billing
|
||||
{} as never, // filesService
|
||||
);
|
||||
|
||||
return { service, lastMileRepository, query };
|
||||
}
|
||||
|
||||
describe('LastMileService.create — haulage guard', () => {
|
||||
it('accepts a booking whose contract chose EDR delivery', async () => {
|
||||
const { service, lastMileRepository } = makeService({});
|
||||
|
||||
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||
|
||||
expect(lastMileRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a booking that chose no road legs on its contract', async () => {
|
||||
const { service, lastMileRepository } = makeService({
|
||||
booking: { tradeDirection: 'IMPORT', firstMile: null, lastMile: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a booking already hauled by the customer’s own truck', async () => {
|
||||
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an import that only chose collection — that is the export leg', async () => {
|
||||
const { service } = makeService({
|
||||
booking: { tradeDirection: 'IMPORT', firstMile: 'Modjo', lastMile: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.create({ bookingId: 'b-1', advancedPayment: 0 } as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns the existing leg without re-checking, so the queue stays idempotent', async () => {
|
||||
const { service, lastMileRepository } = makeService({ hasCustomerTruck: true });
|
||||
lastMileRepository.findAll.mockResolvedValue([{ id: 'lm-existing' }]);
|
||||
|
||||
const result = await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
|
||||
|
||||
expect(result).toEqual({ id: 'lm-existing' });
|
||||
expect(lastMileRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,18 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
|
||||
import {
|
||||
NO_MILE_SERVICE_MESSAGE,
|
||||
SELF_HAUL_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
assertTruckLoad,
|
||||
bookingContainerSizes,
|
||||
remainingBulkTons,
|
||||
} from '../../common/truck-load.util';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
@@ -126,6 +138,47 @@ export class LastMileService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a booking that actually bought EDR delivery belongs in the last-mile
|
||||
* queue, and a booking hauled by the customer's own truck must never also get
|
||||
* an EDR leg.
|
||||
*
|
||||
* Both halves were missing: creation checked payment alone, so any paid
|
||||
* booking could be accepted into the queue — including one whose contract
|
||||
* chose no road legs at all, and one already carrying a customer truck. The
|
||||
* mirror rule existed on the truck side only
|
||||
* (CustomerTruckService.assertSelfHaulPaid), so whichever side acted second
|
||||
* silently opened a competing delivery on the same booking.
|
||||
*/
|
||||
private async assertEdrHaulsThisBooking(bookingId?: string | null): Promise<void> {
|
||||
if (!bookingId) return;
|
||||
|
||||
const [booking] = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection",
|
||||
first_mile_pickup_address AS "firstMile",
|
||||
last_mile_delivery_address AS "lastMile"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
// The road legs are chosen on the contract and copied onto the booking, so
|
||||
// the booking's own addresses answer this without a join.
|
||||
if (booking && !usesEdrMileService(booking)) {
|
||||
throw new BadRequestException(NO_MILE_SERVICE_MESSAGE);
|
||||
}
|
||||
|
||||
const [truck] = await this.dataSource.query(
|
||||
`SELECT 1
|
||||
FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (truck) {
|
||||
throw new BadRequestException(SELF_HAUL_CONFLICT_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||
|
||||
@@ -352,6 +405,8 @@ export class LastMileService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await this.assertEdrHaulsThisBooking(dto.bookingId);
|
||||
|
||||
const record = await this.lastMileRepository.create({
|
||||
bookingId: dto.bookingId,
|
||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||
@@ -573,20 +628,6 @@ export class LastMileService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||
@@ -599,26 +640,9 @@ export class LastMileService {
|
||||
remainingTons: number;
|
||||
complete: boolean;
|
||||
}> {
|
||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||
COALESCE((
|
||||
SELECT SUM(va.net_weight_tons)
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
WHERE lm.booking_id = b.id
|
||||
AND va.deleted_at IS NULL
|
||||
AND va.departed_at IS NOT NULL
|
||||
), 0) AS "hauledTons"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const totalTons = Number(row?.totalTons ?? 0);
|
||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||
// Counts customer trucks as well as EDR ones — a booking hauls by one path
|
||||
// or the other, and "until no tonnage is left" means the same either way.
|
||||
return remainingBulkTons(this.dataSource, bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -643,11 +667,7 @@ export class LastMileService {
|
||||
);
|
||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||
if (totalTons > 0 && remainingTons <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||
);
|
||||
}
|
||||
assertBulkTonnageRemains(totalTons, remainingTons);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -657,33 +677,41 @@ export class LastMileService {
|
||||
const seen = new Set<string>();
|
||||
for (const vehicleId of desired) {
|
||||
const load = loads.get(vehicleId) ?? [];
|
||||
if (load.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
for (const n of load) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
if (seen.has(n)) {
|
||||
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||
}
|
||||
seen.add(n);
|
||||
}
|
||||
// A 40ft container fills the truck; only two 20ft share one.
|
||||
if (load.length > 1) {
|
||||
const sizes = await this.containerSizes(bookingId, load);
|
||||
if (sizes.some((s) => s.includes('40'))) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
assertTruckLoad({
|
||||
containers: load,
|
||||
bookingContainers: bookingNumbers,
|
||||
sizes: await bookingContainerSizes(this.dataSource, bookingId, load),
|
||||
assignedElsewhere: [...seen],
|
||||
});
|
||||
load.forEach((n) => seen.add(n));
|
||||
}
|
||||
|
||||
if (desired.length > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||
assertTruckCountWithinContainers(desired.length, bookingNumbers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* A truck that has already reached the customer cannot have its load rewritten
|
||||
* — the containers on it are a delivered fact, not a plan. The customer side
|
||||
* has locked this since it was built (`Cannot edit a truck that has already
|
||||
* arrived`); the EDR side let a reassignment silently rewrite history.
|
||||
*/
|
||||
private async assertNoArrivedVehicleChanged(
|
||||
current: LastMileVehicleAssignment[],
|
||||
desiredMap: Map<string, string[]>,
|
||||
): Promise<void> {
|
||||
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||
for (const assignment of current) {
|
||||
if (!assignment.arrivedAt) continue;
|
||||
const stillPresent = desiredMap.has(assignment.vehicleId);
|
||||
const load = desiredMap.get(assignment.vehicleId) ?? [];
|
||||
const currentLoad = (assignment.containers ?? []).map((c) =>
|
||||
c.containerNumber.trim().toUpperCase(),
|
||||
);
|
||||
if (!stillPresent || loadKey(load) !== loadKey(currentLoad)) {
|
||||
throw new ConflictException(
|
||||
'This truck has already arrived — its load can no longer be changed or removed',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,6 +746,8 @@ export class LastMileService {
|
||||
where: { lastMileId: id },
|
||||
relations: { containers: true },
|
||||
});
|
||||
await this.assertNoArrivedVehicleChanged(current, desiredMap);
|
||||
|
||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||
// old single-vehicle path has no junction row but must still be freed.
|
||||
|
||||
@@ -2978,6 +2978,31 @@ export class WarehouseInventoryService {
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
// Customer self-haul: the same exit record on the customer's own truck.
|
||||
// Without it a self-haul bulk booking never draws down — hauled tonnage
|
||||
// summed to zero and the booking could take unlimited trucks. Matched by
|
||||
// plate rather than container so bulk trucks (which carry none) count.
|
||||
await manager.query(
|
||||
`UPDATE freight.customer_truck_assignments a
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(a.arrived_at, NOW()),
|
||||
gross_weight_kg = $4,
|
||||
tare_weight_tons = $5,
|
||||
net_weight_tons = $6,
|
||||
updated_at = NOW()
|
||||
WHERE a.booking_id = $1
|
||||
AND UPPER(a.plate_number) = UPPER($2)
|
||||
AND a.departed_at IS NULL
|
||||
AND a.deleted_at IS NULL`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
dto.gateOutTime ?? null,
|
||||
grossTons,
|
||||
tareTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
|
||||
@@ -202,6 +202,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
);
|
||||
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
|
||||
|
||||
// The same for a customer self-haul truck. The prefill above reads the
|
||||
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
|
||||
// and driver to customer_truck_assignments and leaves those columns null — so
|
||||
// a booking with a truck on file still opened this form blank. Only auto-fills
|
||||
// a single truck: with several, the operator picks which one is at the gate.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
if (customerTrucks.length !== 1) return;
|
||||
const [truck] = customerTrucks;
|
||||
setTruckPlateNumber((p) => p || truck.plateNumber || '');
|
||||
setDriverName((p) => p || truck.driverName || '');
|
||||
setTruckType((p) => p || truck.truckType || '');
|
||||
setContainerNumbers((prev) => {
|
||||
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
|
||||
return prev.every((n) => !n) && loaded.length ? loaded : prev;
|
||||
});
|
||||
}, [opened, truckPrefill, isExitStep, customerTrucks]);
|
||||
|
||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||
const assignedTruckOptions = [
|
||||
|
||||
Reference in New Issue
Block a user