mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -157,13 +157,13 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Clearance finalized → customer can proceed to request operation. */
|
||||
/** Document approval finalized → customer can proceed to request operation. */
|
||||
clearanceReady(b: Booking): void {
|
||||
const msg =
|
||||
`Clearance for booking ${b.reference} is complete. ` +
|
||||
`Document approval for booking ${b.reference} is finalized. ` +
|
||||
`You can now proceed to request operation from the portal.`;
|
||||
void this.notifyContact(b, msg, 'CLEARANCE READY');
|
||||
this.inApp(b, 'Clearance complete', msg, {
|
||||
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
|
||||
this.inApp(b, 'Document approval finalized', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -456,7 +456,7 @@ export class ContractClearanceService {
|
||||
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
|
||||
if (!allowed.includes(contract.status)) {
|
||||
throw new ConflictException(
|
||||
`Cannot finalize clearance on status "${contract.status}".`,
|
||||
`Cannot finalize document approval on status "${contract.status}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,23 +374,18 @@ export class ContractsService {
|
||||
if (companyProfileId) {
|
||||
// Business-license files are FileRecords (resource "company_profiles");
|
||||
// carry the live ones by reference. Staged/pending uploads are excluded by
|
||||
// code. Codes are slugged from each document name so they group under
|
||||
// "Profile documents" on the contract detail page.
|
||||
// code. The `business_license` prefix is preserved so the portal groups
|
||||
// them under "Business license" instead of the clearance catch-all — the
|
||||
// index suffix keeps multiple licences distinct.
|
||||
const records = await this.filesService.findByResource(
|
||||
companyProfileId,
|
||||
'company_profiles',
|
||||
);
|
||||
const slug = (name: string) =>
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/\.[a-z0-9]+$/, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '') || 'profile_document';
|
||||
|
||||
records
|
||||
.filter((r) => r.code === 'business_license')
|
||||
.forEach((r, i) => {
|
||||
const code = `${slug(r.name)}_${i + 1}`;
|
||||
const code = `business_license_${i + 1}`;
|
||||
if (existingCodes.has(code)) return;
|
||||
docs.push({
|
||||
code,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3444,19 +3444,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/**
|
||||
* Physical wagons marshalled in the schedule's built train, or null when the
|
||||
* schedule has no built train (or the consist is still empty) and the legacy
|
||||
* locomotive-derived capacity must apply. This count is what caps a built
|
||||
* train's bookings: 50 wagons coupled → 50 wagon slots, no more.
|
||||
* schedule has NO built train and the legacy locomotive-derived capacity must
|
||||
* apply. This count is what caps a built train's bookings: 50 wagons coupled
|
||||
* → 50 wagon slots, no more.
|
||||
*
|
||||
* A built train with an EMPTY consist returns 0, NOT null: zero coupled
|
||||
* wagons means zero capacity. Folding that case into null used to hand an
|
||||
* un-consisted train the abstract locomotive budget, so an empty train
|
||||
* advertised its full maxWagons as free space and accepted bookings the
|
||||
* allocator could never place.
|
||||
*/
|
||||
private async builtTrainWagonCount(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<number | null> {
|
||||
const trainId = schedule.trainSet?.train?.id;
|
||||
if (!trainId) return null;
|
||||
const count = await this.dataSource
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId } });
|
||||
return count > 0 ? count : null;
|
||||
return this.dataSource.getRepository(Wagon).count({ where: { trainId } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7131,9 +7131,19 @@ export class TrainSchedulingService {
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
|
||||
// Recount from the slot rows rather than decrementing the cached counter.
|
||||
// A blind `wagonCount - 1` desyncs the moment two removals race or the
|
||||
// in-memory schedule graph is stale, and the counter is what the schedule
|
||||
// capacity math reads.
|
||||
const remaining = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { trainSetId: schedule.trainSetId },
|
||||
select: { id: true, lengthMeters: true },
|
||||
});
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||||
wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1),
|
||||
totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)),
|
||||
wagonCount: remaining.length,
|
||||
totalLengthMeters: roundTons(
|
||||
remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user