mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -29,3 +29,11 @@ coverage/
|
||||
\#*\#
|
||||
.\#*
|
||||
docker-compose.override.yml
|
||||
|
||||
# cypress e2e artifacts
|
||||
e2e/**/cypress/videos/
|
||||
e2e/**/cypress/screenshots/
|
||||
e2e/**/cypress/downloads/
|
||||
|
||||
# e2e launcher state (ports of the running stack)
|
||||
e2e/freight/.e2e-ports.json
|
||||
|
||||
@@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) =>
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Read-only reference data (yard dropdowns, search filters): any signed-in
|
||||
* staff. Menu/page visibility stays permission-gated in the frontend — this
|
||||
* only lets forms populate their lookups.
|
||||
*/
|
||||
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
|
||||
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { assertExportReceivedWithGrn } from './export-received-gate';
|
||||
|
||||
const db = (rows: unknown[]) =>
|
||||
({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource;
|
||||
|
||||
describe('assertExportReceivedWithGrn', () => {
|
||||
it('passes when the export booking has a received row with a GRN', async () => {
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(db([{ '?column?': 1 }]), {
|
||||
id: 'b-1',
|
||||
tradeDirection: 'EXPORT',
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an export booking with nothing received', async () => {
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('never blocks import — it loads off a train, not out of the warehouse', async () => {
|
||||
const source = db([]);
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }),
|
||||
).resolves.toBeUndefined();
|
||||
// Import short-circuits before querying.
|
||||
expect((source.query as jest.Mock)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not block intercity cargo', async () => {
|
||||
await expect(
|
||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { DataSource, EntityManager } from 'typeorm';
|
||||
|
||||
/** The booking fields the gate needs. */
|
||||
export interface ExportLoadGateBooking {
|
||||
id: string;
|
||||
tradeDirection?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cargo may not be loaded onto its train until it has physically reached
|
||||
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||
* the customer's own truck, and even though a wagon is already allocated. An
|
||||
* allocation is a plan; the GRN is the proof the goods are actually in hand.
|
||||
*
|
||||
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
|
||||
* loaded straight off the allocation, skipping the warehouse, so a booking could
|
||||
* ride the train with nothing ever received. This closes that for export; import
|
||||
* loads off a train and is unaffected.
|
||||
*
|
||||
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||
* fallback older rows use.
|
||||
*/
|
||||
export async function assertExportReceivedWithGrn(
|
||||
db: DataSource | EntityManager,
|
||||
booking: ExportLoadGateBooking,
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'EXPORT') return;
|
||||
|
||||
const [row] = await db.query(
|
||||
`SELECT 1
|
||||
FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = $1
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED')
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[booking.id],
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
throw new BadRequestException(
|
||||
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
|
||||
);
|
||||
}
|
||||
}
|
||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
type MileRecord = {
|
||||
bookingId?: string | null;
|
||||
advancedPayment?: number | string | null;
|
||||
booking?: {
|
||||
cargoTotalWeightVgm?: number | string | null;
|
||||
bookingContainers?: Array<{
|
||||
units?: Array<{ vgmTons?: number | string | null }> | null;
|
||||
}> | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
|
||||
* - Advance payment: mile records are created with advanced_payment 0 — the
|
||||
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
|
||||
* on the booking invoice.
|
||||
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
|
||||
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
|
||||
* Fills both in-memory on the loaded records; nothing is persisted.
|
||||
*/
|
||||
export async function attachMileFinancials(
|
||||
dataSource: DataSource,
|
||||
records: MileRecord[],
|
||||
chargeType: 'FIRST_MILE' | 'LAST_MILE',
|
||||
): Promise<void> {
|
||||
for (const r of records) {
|
||||
const b = r.booking;
|
||||
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
|
||||
const unitTons = (b.bookingContainers ?? []).reduce(
|
||||
(sum, bc) =>
|
||||
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
|
||||
0,
|
||||
);
|
||||
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
|
||||
}
|
||||
|
||||
const needAdvance = records.filter(
|
||||
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
|
||||
);
|
||||
if (!needAdvance.length) return;
|
||||
|
||||
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
|
||||
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
|
||||
FROM freight.invoice_lines il
|
||||
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
|
||||
WHERE i.source = 'booking'
|
||||
AND i.status = 'PAID'
|
||||
AND i.source_id = ANY($1::text[])
|
||||
AND il.charge_type = $2
|
||||
AND il.deleted_at IS NULL
|
||||
GROUP BY i.source_id`,
|
||||
[needAdvance.map((r) => r.bookingId), chargeType],
|
||||
);
|
||||
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
|
||||
for (const r of needAdvance) {
|
||||
const paid = byBooking.get(r.bookingId as string);
|
||||
if (paid) r.advancedPayment = paid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Configured rail distance between two yards (Configuration → Yard Distances).
|
||||
* Route creation resolves each segment's km from here (symmetric lookup:
|
||||
* one A↔B row serves both directions) instead of accepting free-text km,
|
||||
* and snapshots the value onto route_milestones.distance_km.
|
||||
*
|
||||
* Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair
|
||||
* can be re-created.
|
||||
*/
|
||||
export class CreateYardDistances2060000000000 implements MigrationInterface {
|
||||
name = 'CreateYardDistances2060000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.yard_distances (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
from_yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
|
||||
distance_km numeric(10,2) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard
|
||||
ON freight.yard_distances (from_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard
|
||||
ON freight.yard_distances (to_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair
|
||||
ON freight.yard_distances (from_yard_id, to_yard_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
// Backfill from segments already stored on existing routes so editing them
|
||||
// does not immediately fail the "pair not configured" check. One row per
|
||||
// unordered pair; where routes disagree the longest segment wins.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km)
|
||||
SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id))
|
||||
prev_yard_id, yard_id, distance_km
|
||||
FROM (
|
||||
SELECT
|
||||
yard_id,
|
||||
distance_km,
|
||||
LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id
|
||||
FROM freight.route_milestones
|
||||
WHERE deleted_at IS NULL
|
||||
) segments
|
||||
WHERE prev_yard_id IS NOT NULL
|
||||
AND distance_km IS NOT NULL
|
||||
AND distance_km > 0
|
||||
ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC
|
||||
ON CONFLICT DO NOTHING;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
|
||||
* customer_truck_assignments (self-haul only), so EDR trucks need their own
|
||||
* link to the last-mile vehicle assignment that hauled the goods. Generated
|
||||
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||
* the customer in the portal — one per truck, or booking-level (both ids null)
|
||||
* when the truck cannot be resolved.
|
||||
*/
|
||||
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
|
||||
name = 'AddHandoverEdrAssignment2440000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_handovers
|
||||
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
|
||||
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
|
||||
ON freight.booking_handovers (booking_id, edr_assignment_id)
|
||||
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import {
|
||||
collectPermissionKeys,
|
||||
@@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
|
||||
|
||||
@Injectable()
|
||||
export class FreightMeService {
|
||||
getEnrichedProfile(user: TCurrentUser) {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* The JWT session snapshot has no position TYPE, but the backoffice needs it
|
||||
* (GL sub-positions are identified by type key). Resolved live from IAM.
|
||||
*/
|
||||
private async lookupPositionType(
|
||||
positionId: string | undefined,
|
||||
): Promise<{ key: string; name: unknown } | null> {
|
||||
if (!positionId) return null;
|
||||
try {
|
||||
const rows: { key: string; name: unknown }[] = await this.dataSource.query(
|
||||
`SELECT pt.key, pt.name
|
||||
FROM iam.positions p
|
||||
JOIN iam.position_types pt ON pt.id = p.position_type_id
|
||||
WHERE p.id = $1`,
|
||||
[positionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null; // iam schema unreachable — degrade to the old payload shape
|
||||
}
|
||||
}
|
||||
|
||||
async getEnrichedProfile(user: TCurrentUser) {
|
||||
const positionType = await this.lookupPositionType(
|
||||
user.employee?.position?.id,
|
||||
);
|
||||
|
||||
const employee = user.employee
|
||||
? [
|
||||
{
|
||||
@@ -27,6 +57,7 @@ export class FreightMeService {
|
||||
isDelegate: user.employee.position.isDelegate,
|
||||
parentPositionId: user.employee.position.parentPositionId,
|
||||
permissions: user.employee.position.permissions ?? [],
|
||||
positionType,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
@@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Intercity documents approved → booking waits in the ride-along pool. */
|
||||
intercityDocumentsApproved(b: Booking): void {
|
||||
const msg =
|
||||
`Documents for intercity booking ${b.reference} are approved. ` +
|
||||
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
|
||||
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
|
||||
this.inApp(b, 'Documents approved', msg, {
|
||||
type: NotificationType.CLEARANCE_DECISION,
|
||||
});
|
||||
}
|
||||
|
||||
/** Operations returned the operation request for changes. */
|
||||
operationChangesRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
|
||||
@@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: { containers: [] },
|
||||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||||
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
expect(result.blocked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not price containers off a rate configured for a different leg', async () => {
|
||||
@@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
|
||||
expect(result.lineItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
// A mixed booking where only one container size has a configured rate must
|
||||
// hard-block, not silently carry the unconfigured size for free.
|
||||
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||||
const fortyOnly: Rate = {
|
||||
...intercityContainerUsd,
|
||||
id: 'rate-ct-40-only',
|
||||
containerTypeId: 'ct-40',
|
||||
} as Rate;
|
||||
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||||
|
||||
const booking = {
|
||||
id: 'b-5',
|
||||
freightType: 'CONTAINER',
|
||||
tradeDirection: 'DOMESTIC',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: MOJO,
|
||||
destinationYardId: DIRE,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
computeBaseRailLinesWithRates: (
|
||||
b: Booking,
|
||||
input: {
|
||||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||||
},
|
||||
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||||
}
|
||||
).computeBaseRailLinesWithRates(booking, {
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-40', quantity: 2 },
|
||||
{ containerTypeId: 'ct-20', quantity: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.lineItems).toHaveLength(1);
|
||||
expect(result.blocked).toHaveLength(1);
|
||||
expect(result.blocked[0]).toContain('rate is configured');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,8 +137,12 @@ export class BookingPricingService {
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
const {
|
||||
lineItems: baseLines,
|
||||
usedRates: baseRates,
|
||||
warnings: baseWarnings,
|
||||
blocked: baseBlocked,
|
||||
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -247,8 +251,8 @@ export class BookingPricingService {
|
||||
usedRates: [...usedRatesMap.values()],
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
warnings: ruleResult.warnings,
|
||||
hardBlocked: ruleResult.hardBlocked,
|
||||
warnings: [...ruleResult.warnings, ...baseWarnings],
|
||||
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
|
||||
overweightLines,
|
||||
};
|
||||
}
|
||||
@@ -454,7 +458,12 @@ export class BookingPricingService {
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
): Promise<{
|
||||
lineItems: PriceLineItemDto[];
|
||||
usedRates: Rate[];
|
||||
warnings: string[];
|
||||
blocked: string[];
|
||||
}> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
@@ -476,6 +485,8 @@ export class BookingPricingService {
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
@@ -487,47 +498,66 @@ export class BookingPricingService {
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
// It also stands on its own: a contract line prices off the agreed rate
|
||||
// even when nobody configured a live rate for this leg + type yet.
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
// Never price this line off another container type's (or another
|
||||
// route's) rate, and never let an unpriced line through: a booking
|
||||
// that ships a container type nobody configured a rate for would be
|
||||
// carried for free. Hard-block instead — the customer drops the line
|
||||
// or EDR configures the rate.
|
||||
blocked.push(
|
||||
`No ${rateType} rate is configured for ${label} on this route — ` +
|
||||
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
|
||||
'to configure its rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const unitUsd = Number(rate!.rateValue);
|
||||
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
unit: rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
if (lines.length === 0 && evalInput.containers.length === 0) {
|
||||
// Bulk (and any booking with no container lines) still has to price off a
|
||||
// rate configured for this leg — never one belonging to another route.
|
||||
// Container bookings never reach this fallback: their lines price per
|
||||
// container type above or stay unpriced with a warning — falling back to
|
||||
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||||
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||||
const fallback = liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
@@ -570,10 +600,18 @@ export class BookingPricingService {
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
} else if (isBulk) {
|
||||
// Same rule as container lines: bulk freight with no rate on this leg
|
||||
// must not proceed unpriced.
|
||||
blocked.push(
|
||||
`No ${rateType} rate is configured for this route — the booking ` +
|
||||
'cannot be priced. Ask EDR to configure the rate for this ' +
|
||||
'origin → destination.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -349,6 +350,22 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
|
||||
* warehouses module delivered the goods and asks the booking to complete.
|
||||
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
|
||||
*/
|
||||
@OnEvent('import.handover.completed')
|
||||
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
|
||||
try {
|
||||
await this.complete(payload.bookingId);
|
||||
} catch (err) {
|
||||
this.logger.log(
|
||||
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async complete(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
|
||||
@@ -840,6 +857,22 @@ export class BookingTransitionService {
|
||||
}
|
||||
}
|
||||
|
||||
// Intercity: there is no shipment-day request step — an approved booking
|
||||
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
|
||||
// pool keys on. Staff then accept it onto a passing train (that accept
|
||||
// opens the pay window).
|
||||
if (booking.tradeDirection === "DOMESTIC") {
|
||||
const now = new Date();
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "FULLY_EXECUTED",
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
const fresh = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.intercityDocumentsApproved(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: "CLEARANCE_READY",
|
||||
} as never);
|
||||
|
||||
@@ -1213,9 +1213,9 @@ export class BookingsService {
|
||||
|
||||
/**
|
||||
* Batched version of the findById flag: marks each page item whose booking
|
||||
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
|
||||
* dashboard) can show "Approve delivery" for exactly the generated→signed
|
||||
* window. One query for the whole page.
|
||||
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
|
||||
* rows (portal dashboard) can show "Approve delivery" for exactly the
|
||||
* generated→signed window. One query for the whole page.
|
||||
*/
|
||||
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
|
||||
const ids = bookings.map((b) => b.id);
|
||||
@@ -1224,8 +1224,7 @@ export class BookingsService {
|
||||
`SELECT DISTINCT booking_id AS "bookingId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE booking_id = ANY($1::uuid[])
|
||||
AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'`,
|
||||
AND signed_at IS NULL AND deleted_at IS NULL`,
|
||||
[ids],
|
||||
);
|
||||
const pending = new Set(rows.map((r) => r.bookingId));
|
||||
@@ -1559,14 +1558,12 @@ export class BookingsService {
|
||||
schedule?.status ?? null;
|
||||
}
|
||||
|
||||
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
|
||||
// delivery from the portal (booking-based, one per booking). EDR last-mile
|
||||
// handovers are per delivering truck and signed by the receiver at the door,
|
||||
// so they never surface the portal "Approve delivery" action.
|
||||
// A generated-but-unsigned handover means the customer must approve delivery
|
||||
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
|
||||
// per delivering truck (generated on truck exit), signed one by one.
|
||||
const [pendingHandover] = await this.dataSource.query(
|
||||
`SELECT 1 FROM freight.booking_handovers
|
||||
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
|
||||
AND mile_type = 'SELF_HAUL'
|
||||
LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
clearanceSettingCode,
|
||||
clearanceOutputSettingCode,
|
||||
clearanceCodesForBooking,
|
||||
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||
} from './clearance.util';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
|
||||
describe('clearance.util — clearanceSettingCode', () => {
|
||||
it('resolves import container with/without customs', () => {
|
||||
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for DOMESTIC (no clearance gate)', () => {
|
||||
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
||||
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
|
||||
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
|
||||
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
|
||||
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||
);
|
||||
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
|
||||
INTERCITY_DOCUMENTS_SETTING_CODE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
|
||||
const base = {
|
||||
tradeDirection: 'DOMESTIC',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: null,
|
||||
customsClearingEnabled: false,
|
||||
};
|
||||
|
||||
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
|
||||
const general = clearanceCodesForBooking({
|
||||
...base,
|
||||
contractId: 'c1',
|
||||
contractKind: 'GENERAL',
|
||||
} as unknown as Booking);
|
||||
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||
expect(general.outputCode).toBeNull();
|
||||
|
||||
const direct = clearanceCodesForBooking({
|
||||
...base,
|
||||
contractId: null,
|
||||
contractKind: null,
|
||||
} as unknown as Booking);
|
||||
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
|
||||
});
|
||||
|
||||
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
|
||||
const drawdown = clearanceCodesForBooking({
|
||||
...base,
|
||||
contractId: 'c1',
|
||||
contractKind: 'ONE_TIME',
|
||||
} as unknown as Booking);
|
||||
expect(drawdown.inputCode).toBeNull();
|
||||
expect(drawdown.outputCode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity';
|
||||
type Op = 'import' | 'export';
|
||||
type Freight = 'container' | 'bulk';
|
||||
|
||||
/**
|
||||
* The single (admin-configured) document set intercity shipments upload.
|
||||
* DOMESTIC has no customs, so one shared set serves contracts and bookings:
|
||||
* ONE_TIME collects it at contract level, GENERAL per booking — Operations
|
||||
* reviews either way.
|
||||
*/
|
||||
export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
if (tradeDirection === 'EXPORT') return 'export';
|
||||
return null; // DOMESTIC / intercity — no clearance gate
|
||||
return null; // DOMESTIC / intercity — no customs operation
|
||||
}
|
||||
|
||||
function freightFor(freightType: string): Freight {
|
||||
@@ -26,6 +34,9 @@ export function clearanceSettingCode(
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
// Intercity: no customs, but the admin-configured intercity document set is
|
||||
// still collected and ops-reviewed before the shipment may board a train.
|
||||
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
@@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): {
|
||||
const includesCustoms =
|
||||
Boolean(booking.serviceType?.includesCustoms) ||
|
||||
Boolean(booking.customsClearingEnabled);
|
||||
// Intercity drawdowns under a ONE_TIME contract already cleared the intercity
|
||||
// document set on the CONTRACT (post-signature); only GENERAL drawdowns and
|
||||
// direct (contract-less) bookings carry the per-booking set.
|
||||
if (
|
||||
booking.tradeDirection === 'DOMESTIC' &&
|
||||
booking.contractId &&
|
||||
booking.contractKind === 'ONE_TIME'
|
||||
) {
|
||||
return { inputCode: null, outputCode: null, includesCustoms: false };
|
||||
}
|
||||
return {
|
||||
inputCode: clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
|
||||
@@ -198,11 +198,12 @@ export class ContractBookingService {
|
||||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||||
// uploads his own clearance proof on each booking and Operations reviews it
|
||||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||||
// requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
|
||||
// per-booking gate with the intercity document set — ops finalize then puts
|
||||
// the booking straight into the ride-along pool (FULLY_EXECUTED), since
|
||||
// intercity has no shipment-day request step.
|
||||
const generalSelfClear =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC';
|
||||
contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;
|
||||
|
||||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||||
// there is no window and no date — staff accept them onto a train at
|
||||
@@ -300,48 +301,63 @@ export class ContractBookingService {
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
// Everything between the insert and the priced update must be all-or-nothing:
|
||||
// a throw part-way (container persist, weight rules, pricing) would otherwise
|
||||
// leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
|
||||
// occupies the one-time contract's single active-booking slot until the
|
||||
// doc-review sweep expires it — and the clearance cycle still points at the
|
||||
// previous booking, so the hub keeps offering "Rebook" against a dead draft.
|
||||
try {
|
||||
// Persist container lines + per-unit container numbers (container freight only).
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
await this.persistContainers(booking.id, contract, dto);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||
// the customer's retry hits "already has an active booking" against a broken
|
||||
// draft. The customer must fix the contract's rates, then rebook.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.hardDelete(booking.id);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
// Reload with containers to compute the total from contract unit rates × qty.
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (loaded) {
|
||||
if (freightType === 'CONTAINER') {
|
||||
await this.applyWeightResults(loaded);
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
|
||||
// a positive total, so the zero-price gate below misses it — enforce
|
||||
// the pricing hard blocks first. The catch below rolls everything back.
|
||||
if (computed.hardBlocked.length > 0) {
|
||||
throw new BadRequestException(computed.hardBlocked.join('; '));
|
||||
}
|
||||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||
// matched the route/container (or the rate is unset), so the booking is not
|
||||
// valid to ship or invoice. The catch below rolls back the row + its lines.
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
await this.bookingPricingService.createPricingSnapshots(
|
||||
booking.id,
|
||||
computed.usedRates,
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
} catch (err) {
|
||||
await this.bookingsRepository
|
||||
.deleteContainers(booking.id)
|
||||
.catch(() => undefined);
|
||||
await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||||
@@ -734,15 +750,20 @@ export class ContractBookingService {
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||
// A zero price means no contract rate matches — roll the cargo back so
|
||||
// the instance stays CLEARANCE_READY and can be completed again once
|
||||
// the contract rates are fixed (the clearance work is not lost).
|
||||
if (!(computed.totalAmount > 0)) {
|
||||
// the contract rates are fixed (the clearance work is not lost). A
|
||||
// pricing hard block (e.g. one of two container sizes has no rate)
|
||||
// rolls back the same way: a partially-priced total is positive but
|
||||
// the booking must not proceed.
|
||||
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
||||
await this.bookingsRepository.deleteContainers(booking.id);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTotalWeightVgm: 0,
|
||||
} as never);
|
||||
throw new BadRequestException(
|
||||
'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
computed.hardBlocked.length > 0
|
||||
? computed.hardBlocked.join('; ')
|
||||
: 'Booking price came out as 0 — no contract rate matches this ' +
|
||||
'route/cargo. Set the contract rate and try again.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
@@ -1599,17 +1620,23 @@ export class ContractBookingService {
|
||||
throw new BadRequestException('At least one container line is required.');
|
||||
}
|
||||
|
||||
const allowedSizes = new Set(
|
||||
// Size strings arrive in mixed formats ("20ft" from the contract scope,
|
||||
// bare "20" from the rebook seed) — compare numerically so format never
|
||||
// fails a size that IS in scope.
|
||||
const allowedSizesFt = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
);
|
||||
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
|
||||
for (const line of lines) {
|
||||
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
|
||||
if (
|
||||
allowedSizesFt.size &&
|
||||
!allowedSizesFt.has(parseInt(line.containerSize, 10))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is outside the contract scope.`,
|
||||
);
|
||||
@@ -1746,11 +1773,33 @@ export class ContractBookingService {
|
||||
}),
|
||||
);
|
||||
|
||||
// Same size-scope gate persistContainers enforces at create, surfaced as a
|
||||
// blocking preview error so the form can't confirm a size the contract does
|
||||
// not cover. Numeric compare — "20" and "20ft" are the same size.
|
||||
const allowedSizesFt = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((c) => parseInt(c.containerSize ?? '', 10))
|
||||
.filter((n) => Number.isFinite(n)),
|
||||
);
|
||||
const scopeErrors = allowedSizesFt.size
|
||||
? [
|
||||
...new Set(
|
||||
lines
|
||||
.map((l) => l.containerSize)
|
||||
.filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
|
||||
),
|
||||
].map((s) => `Container size ${s} is outside the contract scope.`)
|
||||
: [];
|
||||
|
||||
// The unsaved twin of the booking createUnderContract would write: same
|
||||
// denormalized contract fields, same container-line math. No id → the
|
||||
// pricing service derives wagon counts from the in-memory lines.
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const previewBooking = Object.assign(new Booking(), {
|
||||
// contractId makes the preview price off the contract's frozen rate
|
||||
// snapshots exactly like the persisted booking will — without it the
|
||||
// preview total is 0 on a leg with no live rate and the form blocks.
|
||||
contractId: contract.id,
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
@@ -1858,7 +1907,10 @@ export class ContractBookingService {
|
||||
overweightSurchargeAmount,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
capacityErrors,
|
||||
// Pricing hard blocks (missing rate for a container size / requested
|
||||
// service) ride the capacity-errors channel so the form hard-blocks in
|
||||
// the preview instead of failing at the create call.
|
||||
capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
|
||||
containerClashErrors,
|
||||
spaceErrors,
|
||||
lineItems: computed.lineItems,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a contract during
|
||||
@@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight {
|
||||
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
|
||||
* reviewed by Operations rather than GL.
|
||||
*
|
||||
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
|
||||
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
|
||||
* collects the admin-configured intercity document set after both signatures
|
||||
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
|
||||
* gate and collect the same set per booking instead.
|
||||
*/
|
||||
export function contractClearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
|
||||
@@ -143,6 +143,19 @@ export class ContractNotifierService {
|
||||
this.inApp(c, 'Contract rejected', msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* A later approver sent the contract back to an earlier stage of the chain.
|
||||
* Staff-only: the customer is not involved in an internal send-back — their
|
||||
* contract simply stays "under approval".
|
||||
*/
|
||||
sentBackToStep(c: Contract, targetRole: string, reason: string): void {
|
||||
this.inAppStaff(
|
||||
c,
|
||||
'Contract returned in approval chain',
|
||||
`Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Staff requested changes before approval. */
|
||||
changesRequested(c: Contract, note: string): void {
|
||||
const msg =
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
ContractDocumentSnapshotInput,
|
||||
} from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/** The editable contract-document draft returned for the accept/edit dialog. */
|
||||
@@ -576,17 +577,24 @@ export class ContractTransitionService {
|
||||
|
||||
/**
|
||||
* Reject one approval step (line staff / director / CEO). The rejecting
|
||||
* approver must supply a reason. A rejection is terminal: the whole contract
|
||||
* moves to REJECTED and the customer must create a new one — there is no
|
||||
* resubmit of the same contract. The reason is recorded both on the step and
|
||||
* as a REJECTION review note so it is visible to the customer and the rest of
|
||||
* the approval chain.
|
||||
* approver must supply a reason, and picks where the rejection lands:
|
||||
*
|
||||
* - **To the customer** (`returnToStepId` omitted — the only option for the
|
||||
* first approver): terminal. The whole contract moves to REJECTED with a
|
||||
* REJECTION review note visible to the customer, who must resubmit.
|
||||
* - **To an earlier approver** (`returnToStepId` = an already-APPROVED
|
||||
* earlier step): internal send-back. That step and everything after it
|
||||
* reset to PENDING and the chain re-runs from there; the contract stays
|
||||
* PENDING_APPROVAL and the customer never sees it. E.g. the director can
|
||||
* return a contract to line staff, who fix it and approve again, after
|
||||
* which every later stage re-approves in order.
|
||||
*/
|
||||
async rejectStep(
|
||||
contractId: string,
|
||||
stepId: string,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
returnToStepId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||
@@ -594,6 +602,20 @@ export class ContractTransitionService {
|
||||
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
|
||||
if (!step) throw new BadRequestException('Approval step not found');
|
||||
|
||||
// Only the approver whose turn it is may reject — same ordering rule as
|
||||
// approveStep. Without this, an already-actioned or future step could be
|
||||
// "rejected" and wipe chain state it never owned.
|
||||
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
|
||||
if (!next || next.id !== step.id) {
|
||||
throw new BadRequestException(
|
||||
'Only the current pending approval step can be rejected',
|
||||
);
|
||||
}
|
||||
|
||||
if (returnToStepId) {
|
||||
return this.sendBackToStep(contract, step, actorId, reason, returnToStepId);
|
||||
}
|
||||
|
||||
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
@@ -616,6 +638,67 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal send-back branch of rejectStep: return the contract to an earlier,
|
||||
* already-approved stage of the chain instead of rejecting it outright.
|
||||
* Deliberately NOT the terminal path: no clearance-fee expiry (the contract
|
||||
* is still alive) and no customer-facing REJECTION note — the trail is a
|
||||
* staff note plus a backoffice inbox ping.
|
||||
*/
|
||||
private async sendBackToStep(
|
||||
contract: Contract,
|
||||
rejectingStep: ContractApprovalStep,
|
||||
actorId: string,
|
||||
reason: string,
|
||||
returnToStepId: string,
|
||||
): Promise<Contract> {
|
||||
const target = await this.contractsRepository.findApprovalStepById(
|
||||
contract.id,
|
||||
returnToStepId,
|
||||
);
|
||||
if (!target) throw new BadRequestException('Return-to approval step not found');
|
||||
if (target.stepOrder >= rejectingStep.stepOrder) {
|
||||
throw new BadRequestException(
|
||||
'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId',
|
||||
);
|
||||
}
|
||||
if (target.status !== 'APPROVED') {
|
||||
throw new BadRequestException(
|
||||
`Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Staff-visible trail. Written before the reset so the reason survives the
|
||||
// wipe of per-step notes.
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contract.id,
|
||||
`Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`,
|
||||
'STAFF_NOTE',
|
||||
actorId,
|
||||
'STAFF',
|
||||
);
|
||||
|
||||
// Chain re-runs from the target stage: it and every later step (including
|
||||
// the rejecting one) go back to PENDING. Legacy approved-by columns are
|
||||
// left stale on purpose — approval steps are the source of truth and the
|
||||
// columns get re-stamped on re-approval.
|
||||
await this.contractsRepository.resetApprovalStepsFrom(
|
||||
contract.id,
|
||||
target.stepOrder,
|
||||
);
|
||||
|
||||
// A send-back can only happen mid-chain, so the contract must remain (or
|
||||
// return to) PENDING_APPROVAL — relevant when rejecting from
|
||||
// APPROVED_PENDING_SIGNATURE.
|
||||
await this.contractsRepository.update(contract.id, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
} as never);
|
||||
|
||||
const updated = await this.contractsService.findById(contract.id);
|
||||
this.notifier.sentBackToStep(updated, target.requiredRole, reason);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Approve one approval step in sequence; → APPROVED when all complete. */
|
||||
async approveStep(
|
||||
contractId: string,
|
||||
@@ -1035,8 +1118,8 @@ export class ContractTransitionService {
|
||||
};
|
||||
|
||||
// A clearance gate applies whenever a clearance doc set resolves — Path B
|
||||
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
|
||||
// resolves to null on both paths and skips straight to executed.
|
||||
// (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the
|
||||
// intercity document set (DOMESTIC, ops-reviewed like Path A).
|
||||
const clearanceCode = contractClearanceSettingCode(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
|
||||
@@ -442,7 +442,10 @@ export class ContractsController {
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
])
|
||||
@ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)',
|
||||
})
|
||||
rejectStep(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||
@@ -454,6 +457,7 @@ export class ContractsController {
|
||||
stepId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
dto.returnToStepId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// direct download. Loaded separately to keep pagination counts correct.
|
||||
await this.attachContractFiles(items);
|
||||
await this.attachClearancePhases(items);
|
||||
await this.attachRejectionNotes(items);
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||
return {
|
||||
@@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the latest REJECTION review-note body to each REJECTED contract so
|
||||
* list consumers (portal rows, backoffice queues) can show why without a
|
||||
* per-contract detail fetch. One query per page, like `attachContractFiles`.
|
||||
*/
|
||||
private async attachRejectionNotes(contracts: Contract[]): Promise<void> {
|
||||
const rejected = contracts.filter((c) => c.status === 'REJECTED');
|
||||
if (rejected.length === 0) return;
|
||||
const ids = rejected.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; body: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, body
|
||||
FROM freight.contract_review_notes
|
||||
WHERE contract_id = ANY($1)
|
||||
AND note_type = 'REJECTION'
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY contract_id, created_at DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.body]));
|
||||
for (const contract of rejected) {
|
||||
contract.latestRejectionNote = byContract.get(contract.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusCounts(): Promise<Record<string, number>> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('contract')
|
||||
@@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send-back reset: every step at or after `fromStepOrder` returns to PENDING
|
||||
* with its actor/verdict cleared, so the chain re-runs from that stage. The
|
||||
* send-back reason lives in the review-note trail, not on the wiped steps.
|
||||
*/
|
||||
async resetApprovalStepsFrom(
|
||||
contractId: string,
|
||||
fromStepOrder: number,
|
||||
): Promise<void> {
|
||||
await this.dataSource
|
||||
.getRepository(ContractApprovalStep)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null })
|
||||
.where('contract_id = :contractId', { contractId })
|
||||
.andWhere('step_order >= :fromStepOrder', { fromStepOrder })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Check if all approval steps are approved. */
|
||||
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
|
||||
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
|
||||
|
||||
@@ -636,6 +636,47 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the rejection reason. The approval-step note is wiped on
|
||||
// send-back resets, so the review-note trail is the only durable source.
|
||||
if (contract.status === 'REJECTED') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'REJECTION',
|
||||
);
|
||||
contract.latestRejectionNote = note?.body ?? null;
|
||||
} catch {
|
||||
contract.latestRejectionNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the send-back reason to the returned-to approver, but only while
|
||||
// it is still actionable: once any step acts after the send-back the note
|
||||
// is stale and stays out of the response (the trail keeps it in the DB).
|
||||
if (contract.status === 'PENDING_APPROVAL') {
|
||||
try {
|
||||
const note = await this.contractsRepository.findLatestReviewNote(
|
||||
contract.id,
|
||||
'STAFF_NOTE',
|
||||
);
|
||||
// Stale when any step acted after it (send-back resolved) or when the
|
||||
// chain itself is newer than the note (fresh cycle after a resubmit).
|
||||
const staleAfter = Math.max(
|
||||
0,
|
||||
...(contract.approvalSteps ?? []).flatMap((s) => [
|
||||
s.actedAt ? new Date(s.actedAt).getTime() : 0,
|
||||
s.createdAt ? new Date(s.createdAt).getTime() : 0,
|
||||
]),
|
||||
);
|
||||
contract.latestSendBackNote =
|
||||
note && new Date(note.createdAt).getTime() > staleAfter
|
||||
? note.body
|
||||
: null;
|
||||
} catch {
|
||||
contract.latestSendBackNote = null;
|
||||
}
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
|
||||
export class ApproveStepDto {
|
||||
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||
@@ -26,6 +26,22 @@ export class RejectStepDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
|
||||
/**
|
||||
* Where the rejection lands. Omitted → the customer: the contract goes to
|
||||
* REJECTED and the customer must resubmit (unchanged legacy behaviour, and
|
||||
* the only option for the first approver in the chain). Set to an EARLIER
|
||||
* approved step's id → send-back: that step and everything after it reset to
|
||||
* PENDING and the chain re-runs from there; the contract never leaves
|
||||
* PENDING_APPROVAL and the customer is not involved.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
returnToStepId?: string;
|
||||
}
|
||||
|
||||
export class CancelContractDto {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
'OTHER',
|
||||
] as const;
|
||||
export type IncidentType = (typeof INCIDENT_TYPES)[number];
|
||||
|
||||
|
||||
@@ -326,4 +326,19 @@ export class Contract extends BaseEntity {
|
||||
* asked them to fix. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestChangeRequestNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent REJECTION review note, attached by
|
||||
* ContractsService.findById when status is REJECTED so both backoffice and
|
||||
* portal can show why. Lives in contract_review_notes, not a column here.
|
||||
*/
|
||||
latestRejectionNote?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent send-back STAFF_NOTE, attached by
|
||||
* ContractsService.findById while the contract is PENDING_APPROVAL and no
|
||||
* approval step has acted since the send-back. Lives in
|
||||
* contract_review_notes, not a column here.
|
||||
*/
|
||||
latestSendBackNote?: string | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes
|
||||
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||
import { BookingsRepository } from "../bookings/bookings.repository";
|
||||
import { DriversService } from "../drivers/drivers.service";
|
||||
@@ -66,6 +67,7 @@ export class FirstMileService {
|
||||
for (const r of records) {
|
||||
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'FIRST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SELF_HAUL_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
@@ -88,6 +89,7 @@ export class LastMileService {
|
||||
for (const r of records) {
|
||||
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||
}
|
||||
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
|
||||
}
|
||||
|
||||
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||
@@ -333,6 +335,8 @@ export class LastMileService {
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}>
|
||||
> {
|
||||
const [lm] = await this.lastMileRepository.findAll({
|
||||
@@ -347,9 +351,11 @@ export class LastMileService {
|
||||
? lm.vehicleAssignments.map((va) => ({
|
||||
vehicle: va.vehicle,
|
||||
containerNumber: va.containerNumber ?? null,
|
||||
arrivedAt: va.arrivedAt ?? null,
|
||||
departedAt: va.departedAt ?? null,
|
||||
}))
|
||||
: lm.vehicle
|
||||
? [{ vehicle: lm.vehicle, containerNumber: null }]
|
||||
? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }]
|
||||
: [];
|
||||
|
||||
const out: Array<{
|
||||
@@ -361,8 +367,10 @@ export class LastMileService {
|
||||
driverPhone: string | null;
|
||||
truckType: string | null;
|
||||
containerNumber: string | null;
|
||||
arrivedAt: string | null;
|
||||
departedAt: string | null;
|
||||
}> = [];
|
||||
for (const { vehicle, containerNumber } of sources) {
|
||||
for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) {
|
||||
if (!vehicle) continue;
|
||||
let driverName = vehicle.assignedDriverName ?? null;
|
||||
let driverLicense: string | null = null;
|
||||
@@ -386,6 +394,8 @@ export class LastMileService {
|
||||
driverPhone,
|
||||
truckType: vehicle.vehicleType || null,
|
||||
containerNumber,
|
||||
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||||
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
LOCOMOTIVE_STATUSES,
|
||||
@@ -21,4 +22,29 @@ export class FilterLocomotivesDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
/**
|
||||
* Drop locomotives already coupled to a built train — the train-builder
|
||||
* "change locomotives" picker uses this so a loco that belongs to another
|
||||
* train is never offered (the backend would 409 on save anyway). Combine with
|
||||
* `excludeTrainId` to keep the CURRENT train's own locos in the list.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Exclude locomotives already coupled to any built train',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
excludeCoupled?: boolean;
|
||||
|
||||
/**
|
||||
* When `excludeCoupled` is set, locos coupled to THIS train are still kept
|
||||
* (they are valid picks — you are editing that train's consist).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Train id whose own coupled locomotives are NOT excluded',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
excludeTrainId?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Locomotive } from './entities/locomotive.entity';
|
||||
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
|
||||
|
||||
@Injectable()
|
||||
export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
@@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* List locomotives for the train-builder coupling picker: the usual
|
||||
* status/type/yard filters, plus optional exclusion of any loco already
|
||||
* coupled to a built train. `keepTrainId` spares that one train's own locos
|
||||
* from the exclusion so they stay selectable while editing its consist.
|
||||
*/
|
||||
findForCoupling(opts: {
|
||||
status?: LocomotiveStatus;
|
||||
locomotiveType?: LocomotiveType;
|
||||
currentYardId?: string;
|
||||
excludeCoupled?: boolean;
|
||||
keepTrainId?: string;
|
||||
}): Promise<Locomotive[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('locomotive')
|
||||
.leftJoinAndSelect('locomotive.currentYard', 'currentYard')
|
||||
.orderBy('locomotive.code', 'ASC');
|
||||
|
||||
if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status });
|
||||
if (opts.locomotiveType)
|
||||
qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType });
|
||||
if (opts.currentYardId)
|
||||
qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId });
|
||||
|
||||
if (opts.excludeCoupled) {
|
||||
// NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the
|
||||
// consist being edited still lists its current locomotives.
|
||||
const sub = this.repository.manager
|
||||
.getRepository(TrainLocomotive)
|
||||
.createQueryBuilder('tl')
|
||||
.select('1')
|
||||
.where('tl.locomotiveId = locomotive.id');
|
||||
if (opts.keepTrainId) {
|
||||
sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId });
|
||||
}
|
||||
qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters());
|
||||
}
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* A live locomotive already holding this name, compared the same way the
|
||||
* `UQ_locomotives_name_active` index compares: case- and whitespace-
|
||||
|
||||
@@ -29,6 +29,17 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
// The coupling picker needs a NOT-EXISTS against the train link table, so it
|
||||
// takes the query-builder path; the plain list keeps the simple where.
|
||||
if (filter.excludeCoupled) {
|
||||
return this.locomotivesRepository.findForCoupling({
|
||||
status: filter.status as LocomotiveStatus | undefined,
|
||||
locomotiveType: filter.locomotiveType as LocomotiveType | undefined,
|
||||
currentYardId: filter.currentYardId,
|
||||
excludeCoupled: true,
|
||||
keepTrainId: filter.excludeTrainId,
|
||||
});
|
||||
}
|
||||
return this.locomotivesRepository.findAll({
|
||||
where: {
|
||||
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
|
||||
|
||||
@@ -4,25 +4,22 @@ import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { RouteStatus } from '../entities/route.entity';
|
||||
|
||||
/**
|
||||
* Segment distances are no longer part of the payload — they are resolved
|
||||
* from the configured yard_distances table (Configuration → Yard Distances)
|
||||
* and snapshotted onto route_milestones at create/update.
|
||||
*/
|
||||
export class CreateRouteMilestoneDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
distanceKm?: number;
|
||||
}
|
||||
|
||||
export class CreateRouteDto {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { YardDistance } from '../rule-engine/entities/yard-distance.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
@@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
/** Order-insensitive key: distances are symmetric. */
|
||||
const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
constructor(
|
||||
@@ -183,47 +187,63 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async validateMilestones(
|
||||
milestones: Array<{ yardId: string; distanceKm?: number }>,
|
||||
) {
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
}
|
||||
|
||||
const normalized = milestones.map((milestone, index) => {
|
||||
const distanceKm =
|
||||
index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
|
||||
if (index > 0 && (distanceKm == null || distanceKm < 0)) {
|
||||
throw new BadRequestException(
|
||||
`Enter segment KM for stop ${index + 1} (from previous yard).`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
distanceKm,
|
||||
};
|
||||
});
|
||||
|
||||
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
|
||||
const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))];
|
||||
const yards = await this.dataSource
|
||||
.getRepository(Yard)
|
||||
.find({ where: uniqueYardIds.map((id) => ({ id })) });
|
||||
const yardIds = new Set(yards.map((yard) => yard.id));
|
||||
|
||||
for (const milestone of normalized) {
|
||||
for (const milestone of milestones) {
|
||||
if (!yardIds.has(milestone.yardId)) {
|
||||
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
|
||||
if (milestones[0].yardId === milestones[milestones.length - 1].yardId) {
|
||||
throw new BadRequestException('Origin and destination yards must be different');
|
||||
}
|
||||
|
||||
const originYardId = normalized[0].yardId;
|
||||
const destinationYardId = normalized[normalized.length - 1].yardId;
|
||||
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
|
||||
const distanceByPair = await this.loadDistanceLookup(uniqueYardIds);
|
||||
|
||||
// Segment km come from the configured yard-distance table, not the payload
|
||||
// — a route can only be built over pairs an admin has entered. Distances
|
||||
// are symmetric, so an A→B row also serves B→A.
|
||||
const missingPairs: string[] = [];
|
||||
const normalized = milestones.map((milestone, index) => {
|
||||
if (index === 0) {
|
||||
return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 };
|
||||
}
|
||||
const previousYardId = milestones[index - 1].yardId;
|
||||
const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId));
|
||||
if (distanceKm == null) {
|
||||
const from = yardById.get(previousYardId);
|
||||
const to = yardById.get(milestone.yardId);
|
||||
missingPairs.push(
|
||||
`${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
yardId: milestone.yardId,
|
||||
sequenceNo: index + 1,
|
||||
distanceKm: distanceKm ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
if (missingPairs.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`No distance configured for: ${missingPairs.join(', ')}. ` +
|
||||
'Add the missing yard distances in Configuration → Yard Distances first.',
|
||||
);
|
||||
}
|
||||
|
||||
const originYardId = milestones[0].yardId;
|
||||
const destinationYardId = milestones[milestones.length - 1].yardId;
|
||||
const direction = deriveTradeDirection(
|
||||
yardById.get(originYardId) ?? { country: null },
|
||||
yardById.get(destinationYardId) ?? { country: null },
|
||||
@@ -236,4 +256,17 @@ export class RoutesService {
|
||||
milestones: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
/** Order-insensitive pair → km map over every configured distance touching the yards. */
|
||||
private async loadDistanceLookup(yardIds: string[]): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(YardDistance)
|
||||
.find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] });
|
||||
|
||||
const lookup = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
@@ -18,7 +19,7 @@ export class CargoTypesController {
|
||||
constructor(private readonly service: CargoTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('cargo-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List cargo types' })
|
||||
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class CargoTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('cargo-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a cargo type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
@@ -18,7 +19,7 @@ export class ContainerTypesController {
|
||||
constructor(private readonly service: ContainerTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('container-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List container types' })
|
||||
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class ContainerTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('container-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a container type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -18,7 +19,7 @@ export class ServiceTypesController {
|
||||
constructor(private readonly service: ServiceTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('service-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List service types' })
|
||||
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +42,7 @@ export class ServiceTypesController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('service-types')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a service type by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -16,14 +17,14 @@ export class ShippingLinesController {
|
||||
constructor(private readonly service: ShippingLinesService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('shipping-lines')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('shipping-lines')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a shipping line by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
|
||||
import { YardDistancesService } from '../services/yard-distances.service';
|
||||
|
||||
@ApiTags('yard-distances')
|
||||
@Controller('yard-distances')
|
||||
@ApiBearerAuth()
|
||||
export class YardDistancesController {
|
||||
constructor(private readonly service: YardDistancesService) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List yard distances' })
|
||||
findAll(@Query() query: ListYardDistancesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a yard distance by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('yard-distances')
|
||||
@ApiOperation({ summary: 'Create a yard distance' })
|
||||
create(@Body() dto: CreateYardDistanceDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('yard-distances')
|
||||
@ApiOperation({ summary: 'Update a yard distance' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('yard-distances')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a yard distance' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
} from '@nestjs/common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { RuleEngineManage } from '../../../common/rule-engine-guards';
|
||||
import { StaffReference } from '../../../common/booking-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
@@ -18,7 +19,9 @@ export class YardsController {
|
||||
constructor(private readonly service: YardsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
// Reference read: every staff form/search needs the yard list (origin /
|
||||
// destination pickers), so login is the only requirement.
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: ListYardsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
@@ -41,7 +44,7 @@ export class YardsController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('yards')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get a yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.findById(id);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsUUID, Min } from 'class-validator';
|
||||
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
export class CreateYardDistanceDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
fromYardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
toYardId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
distanceKm!: number;
|
||||
}
|
||||
@@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto {
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListYardDistancesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Return only distances touching this yard.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'distanceKm'])
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateYardDistanceDto } from './create-yard-distance.dto';
|
||||
|
||||
export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Yard } from './yard.entity';
|
||||
|
||||
/**
|
||||
* Configured rail distance between two yards. Route creation reads segment
|
||||
* kilometres from here (symmetric: A→B serves B→A too) instead of taking
|
||||
* them as free-text input — see RoutesService.validateMilestones.
|
||||
*
|
||||
* Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB
|
||||
* (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a
|
||||
* soft-deleted pair can be re-created.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'yard_distances' })
|
||||
@Index(['fromYardId'])
|
||||
@Index(['toYardId'])
|
||||
export class YardDistance extends BaseEntity {
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'from_yard_id' })
|
||||
fromYard?: Yard;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'to_yard_id' })
|
||||
toYard?: Yard;
|
||||
|
||||
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 })
|
||||
distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { YardDistance } from '../entities/yard-distance.entity';
|
||||
|
||||
export interface IYardDistancesRepository {
|
||||
findById(id: string): Promise<YardDistance | null>;
|
||||
/** Exact or reverse pair — distances are symmetric (A→B serves B→A). */
|
||||
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null>;
|
||||
/** All rows touching any of the given yards, for batch segment lookups. */
|
||||
findTouchingYards(yardIds: string[]): Promise<YardDistance[]>;
|
||||
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>>;
|
||||
create(data: Partial<YardDistance>): Promise<YardDistance>;
|
||||
update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY');
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Brackets, DataSource, In, Repository } from 'typeorm';
|
||||
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { YardDistance } from '../entities/yard-distance.entity';
|
||||
import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface';
|
||||
|
||||
@Injectable()
|
||||
export class YardDistancesRepository implements IYardDistancesRepository {
|
||||
private readonly repo: Repository<YardDistance>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repo = this.dataSource.getRepository(YardDistance);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<YardDistance | null> {
|
||||
return this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { fromYard: true, toYard: true },
|
||||
});
|
||||
}
|
||||
|
||||
findBetween(fromYardId: string, toYardId: string): Promise<YardDistance | null> {
|
||||
return this.repo.findOne({
|
||||
where: [
|
||||
{ fromYardId, toYardId },
|
||||
{ fromYardId: toYardId, toYardId: fromYardId },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
findTouchingYards(yardIds: string[]): Promise<YardDistance[]> {
|
||||
if (!yardIds.length) return Promise.resolve([]);
|
||||
return this.repo.find({
|
||||
where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }],
|
||||
});
|
||||
}
|
||||
|
||||
/** Paged list with server-side search on either yard's label/code. */
|
||||
findPaged(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistance>> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('yardDistance')
|
||||
.leftJoinAndSelect('yardDistance.fromYard', 'fromYard')
|
||||
.leftJoinAndSelect('yardDistance.toYard', 'toYard')
|
||||
.orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC')
|
||||
.addOrderBy('fromYard.label', 'ASC');
|
||||
|
||||
if (query.yardId) {
|
||||
qb.andWhere(
|
||||
new Brackets((w) =>
|
||||
w
|
||||
.where('yardDistance.fromYardId = :yardId', { yardId: query.yardId })
|
||||
.orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
new Brackets((w) =>
|
||||
w
|
||||
.where('fromYard.label ILIKE :search', { search: `%${query.search}%` })
|
||||
.orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` })
|
||||
.orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` })
|
||||
.orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return paginateQuery(qb, query);
|
||||
}
|
||||
|
||||
async create(data: Partial<YardDistance>): Promise<YardDistance> {
|
||||
const entity = this.repo.create(data);
|
||||
const saved = await this.repo.save(entity);
|
||||
return (await this.findById(saved.id)) ?? saved;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<YardDistance>): Promise<YardDistance | null> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<void> {
|
||||
await this.repo.softDelete(id);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller';
|
||||
import { ServiceTypesController } from './controllers/service-types.controller';
|
||||
import { ShippingLinesController } from './controllers/shipping-lines.controller';
|
||||
import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller';
|
||||
import { YardDistancesController } from './controllers/yard-distances.controller';
|
||||
import { YardsController } from './controllers/yards.controller';
|
||||
|
||||
import { ApprovalRule } from './entities/approval-rule.entity';
|
||||
@@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity';
|
||||
import { ShippingLine } from './entities/shipping-line.entity';
|
||||
import { WeightLimitRule } from './entities/weight-limit-rule.entity';
|
||||
import { Yard } from './entities/yard.entity';
|
||||
import { YardDistance } from './entities/yard-distance.entity';
|
||||
import { YardFacility } from './entities/yard-facility.entity';
|
||||
|
||||
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
|
||||
@@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface';
|
||||
import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface';
|
||||
import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface';
|
||||
import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface';
|
||||
import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface';
|
||||
import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface';
|
||||
|
||||
import { ApprovalRulesRepository } from './repositories/approval-rules.repository';
|
||||
@@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository';
|
||||
import { ServiceTypesRepository } from './repositories/service-types.repository';
|
||||
import { ShippingLinesRepository } from './repositories/shipping-lines.repository';
|
||||
import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository';
|
||||
import { YardDistancesRepository } from './repositories/yard-distances.repository';
|
||||
import { YardsRepository } from './repositories/yards.repository';
|
||||
|
||||
import { ApprovalRulesService } from './services/approval-rules.service';
|
||||
@@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service';
|
||||
import { ShippingLinesService } from './services/shipping-lines.service';
|
||||
import { WeightLimitRulesService } from './services/weight-limit-rules.service';
|
||||
import { YardsService } from './services/yards.service';
|
||||
import { YardDistancesService } from './services/yard-distances.service';
|
||||
import { YardFacilitiesService } from './services/yard-facilities.service';
|
||||
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
@@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ServiceType,
|
||||
WeightLimitRule,
|
||||
Yard,
|
||||
YardDistance,
|
||||
YardFacility,
|
||||
ShippingLine,
|
||||
Rate,
|
||||
@@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ServiceTypesController,
|
||||
WeightLimitRulesController,
|
||||
YardsController,
|
||||
YardDistancesController,
|
||||
ShippingLinesController,
|
||||
RatesController,
|
||||
ApprovalRulesController,
|
||||
@@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
{ provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository },
|
||||
YardsRepository,
|
||||
{ provide: YARDS_REPOSITORY, useExisting: YardsRepository },
|
||||
YardDistancesRepository,
|
||||
{ provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository },
|
||||
ShippingLinesRepository,
|
||||
{ provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository },
|
||||
RatesRepository,
|
||||
@@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
ServiceTypesService,
|
||||
WeightLimitRulesService,
|
||||
YardsService,
|
||||
YardDistancesService,
|
||||
YardFacilitiesService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
@@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
WeightLimitRulesService,
|
||||
PriorityConfigsService,
|
||||
YardsService,
|
||||
YardDistancesService,
|
||||
YardFacilitiesService,
|
||||
ShippingLinesService,
|
||||
RatesService,
|
||||
@@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
SHIPPING_LINES_REPOSITORY,
|
||||
YARDS_REPOSITORY,
|
||||
YARD_DISTANCES_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class RuleEngineModule {}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { RuleEngineService } from './rule-engine.service';
|
||||
import type { BookingEvaluationInput } from './rule-engine.service';
|
||||
import type { Rate } from './entities/rate.entity';
|
||||
|
||||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||||
const hazardRate: Rate = {
|
||||
id: 'rate-hazard',
|
||||
rateType: 'HAZARD_SURCHARGE',
|
||||
trigger: 'HAZARDOUS',
|
||||
rateValue: 50,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let ratesRepo: { findLiveRates: jest.Mock };
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||||
ratesRepo as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||||
{} as never, // dataSource (unused by evaluate)
|
||||
);
|
||||
});
|
||||
|
||||
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
containers: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||||
});
|
||||
|
||||
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||||
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||||
const result = await service.evaluate(input({ isHazardous: true }));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||||
const result = await service.evaluate(input({}));
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||||
const result = await service.evaluate(
|
||||
input({
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: 2,
|
||||
vgmPerUnitTons: 10,
|
||||
totalVgmTons: 20,
|
||||
reeferQuantity: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.hardBlocked).toHaveLength(1);
|
||||
expect(result.hardBlocked[0]).toContain('reefer');
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
} from './interfaces/shipping-lines.repository.interface';
|
||||
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
@@ -245,6 +249,48 @@ export class RuleEngineService {
|
||||
liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
|
||||
);
|
||||
|
||||
// A handling service the booking asks for (booking-level flag OR any
|
||||
// per-container opt-in count) with no LIVE surcharge rate configured is a
|
||||
// hard block — pricing would otherwise ship the service for free. System-
|
||||
// derived charges (consolidation, overweight, shipping line, lashing) stay
|
||||
// exempt: the customer never opted into those, so they must not block.
|
||||
const requestedServices: Array<{
|
||||
trigger: RateTrigger;
|
||||
wanted: boolean;
|
||||
label: string;
|
||||
}> = [
|
||||
{
|
||||
trigger: 'HAZARDOUS',
|
||||
wanted:
|
||||
truthy(input.isHazardous) ||
|
||||
input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
|
||||
label: 'hazardous cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'REEFER',
|
||||
wanted:
|
||||
hasReefer ||
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
wanted:
|
||||
truthy(input.withReturn) ||
|
||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||
label: 'empty-container return',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
hardBlocked.push(
|
||||
`No ${svc.label} surcharge rate is configured — the booking cannot ` +
|
||||
`be priced with this service. Remove the ${svc.label} option or ` +
|
||||
'ask EDR to configure its rate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rate of surchargeRates) {
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
@@ -438,9 +484,6 @@ export class RuleEngineService {
|
||||
hasLashing: boolean;
|
||||
},
|
||||
): boolean {
|
||||
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
switch (trigger) {
|
||||
case 'HAZARDOUS':
|
||||
return truthy(state.isHazardous);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto';
|
||||
import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto';
|
||||
import { YardDistance } from '../entities/yard-distance.entity';
|
||||
import {
|
||||
IYardDistancesRepository,
|
||||
YARD_DISTANCES_REPOSITORY,
|
||||
} from '../interfaces/yard-distances.repository.interface';
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
/**
|
||||
* Flat row shape for the backoffice config table: the yard relations stay for
|
||||
* API consumers, plus label fields the generic rule-engine grid can render.
|
||||
*/
|
||||
export type YardDistanceRow = YardDistance & {
|
||||
fromYardLabel: string;
|
||||
toYardLabel: string;
|
||||
};
|
||||
|
||||
const yardDisplay = (yard?: { label?: string; code?: string } | null): string =>
|
||||
yard?.label ?? yard?.code ?? '—';
|
||||
|
||||
const toRow = (entity: YardDistance): YardDistanceRow =>
|
||||
Object.assign(entity, {
|
||||
fromYardLabel: yardDisplay(entity.fromYard),
|
||||
toYardLabel: yardDisplay(entity.toYard),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class YardDistancesService {
|
||||
constructor(
|
||||
@Inject(YARD_DISTANCES_REPOSITORY)
|
||||
private readonly repository: IYardDistancesRepository,
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
) {}
|
||||
|
||||
async findAll(query: ListYardDistancesQueryDto): Promise<PaginatedResponse<YardDistanceRow>> {
|
||||
const page = await this.repository.findPaged(query);
|
||||
return { ...page, items: page.items.map(toRow) };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<YardDistanceRow> {
|
||||
const entity = await this.repository.findById(id);
|
||||
if (!entity) throw new NotFoundException(`Yard distance ${id} not found`);
|
||||
return toRow(entity);
|
||||
}
|
||||
|
||||
async create(dto: CreateYardDistanceDto): Promise<YardDistanceRow> {
|
||||
await this.assertValidPair(dto.fromYardId, dto.toYardId);
|
||||
|
||||
const created = await this.repository.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
distanceKm: dto.distanceKm.toFixed(2),
|
||||
});
|
||||
return toRow(created);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateYardDistanceDto): Promise<YardDistanceRow> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
const fromYardId = dto.fromYardId ?? existing.fromYardId;
|
||||
const toYardId = dto.toYardId ?? existing.toYardId;
|
||||
if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) {
|
||||
await this.assertValidPair(fromYardId, toYardId, id);
|
||||
}
|
||||
|
||||
const updated = await this.repository.update(id, {
|
||||
fromYardId,
|
||||
toYardId,
|
||||
...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}),
|
||||
});
|
||||
if (!updated) throw new NotFoundException(`Yard distance ${id} not found`);
|
||||
return toRow(updated);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Both yards must exist and differ, and the pair must not already be
|
||||
* configured in either direction — distances are symmetric, so an A→B row
|
||||
* already covers B→A.
|
||||
*/
|
||||
private async assertValidPair(
|
||||
fromYardId: string,
|
||||
toYardId: string,
|
||||
ignoreId?: string,
|
||||
): Promise<void> {
|
||||
if (fromYardId === toYardId) {
|
||||
throw new BadRequestException('From and to yards must be different');
|
||||
}
|
||||
|
||||
const [fromYard, toYard] = await Promise.all([
|
||||
this.yardsRepository.findById(fromYardId),
|
||||
this.yardsRepository.findById(toYardId),
|
||||
]);
|
||||
if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`);
|
||||
if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`);
|
||||
|
||||
const existing = await this.repository.findBetween(fromYardId, toYardId);
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(
|
||||
`A distance between ${fromYard.label} and ${toYard.label} is already configured`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expireLeftoverExportDay — export day sweep', () => {
|
||||
const exportSchedule = {
|
||||
id: scheduleId,
|
||||
direction: 'EXPORT',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-dest',
|
||||
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||
windowPhase: 'DONE',
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
};
|
||||
let unacceptedSpy: jest.SpyInstance;
|
||||
let poolSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
unacceptedSpy = jest
|
||||
.spyOn(service, 'expireUnacceptedForRouteDay')
|
||||
.mockResolvedValue(undefined);
|
||||
poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('ignores non-export schedules', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
...exportSchedule,
|
||||
direction: 'IMPORT',
|
||||
});
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while another export train on the day can still take bookings', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers while a FULL train still has live pay windows', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
bookingsRepository.findReservedForSchedule.mockResolvedValue([
|
||||
{
|
||||
paymentStatus: 'PENDING',
|
||||
status: 'AWAITING_PAYMENT',
|
||||
paymentDeadline: new Date(Date.now() + 60_000),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).not.toHaveBeenCalled();
|
||||
expect(poolSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => {
|
||||
trainSchedulesRepository.findById.mockResolvedValue(exportSchedule);
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
exportSchedule,
|
||||
{
|
||||
...exportSchedule,
|
||||
id: 'sched-2',
|
||||
windowPhase: 'OPEN',
|
||||
bookingWindowStatus: 'FULL',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.expireLeftoverExportDay(scheduleId);
|
||||
|
||||
expect(unacceptedSpy).toHaveBeenCalledWith({
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-dest',
|
||||
day: '2026-06-20',
|
||||
});
|
||||
expect(poolSpy).toHaveBeenCalledWith(scheduleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||
const importGeneral = {
|
||||
id: 'b1',
|
||||
@@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptIntercity — export pay window expires at window close', () => {
|
||||
const exportScheduleId = 'export-train';
|
||||
// Window closes in 30 minutes; the configured pay window is 60 minutes.
|
||||
const closesAt = new Date(Date.now() + 30 * 60_000);
|
||||
|
||||
const waiting = {
|
||||
id: 'ic-1',
|
||||
reference: 'IC-1',
|
||||
isGovernment: false,
|
||||
status: 'FULLY_EXECUTED',
|
||||
trainScheduleId: null,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 10,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
|
||||
let scheduleRepo: { findOne: jest.Mock };
|
||||
let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
bookingRepo = dataSource.getRepository();
|
||||
bookingRepo.findOne.mockResolvedValue(waiting);
|
||||
scheduleRepo = { findOne: jest.fn() };
|
||||
// reserve() reads the target schedule to clamp export deadlines — route
|
||||
// TrainSchedule reads to their own repo, everything else stays as before.
|
||||
dataSource.getRepository.mockImplementation((entity?: { name?: string }) =>
|
||||
entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo,
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps the intercity pay deadline to the export window close', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: closesAt,
|
||||
scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000),
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, exportScheduleId);
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
paymentDeadline: closesAt,
|
||||
}),
|
||||
);
|
||||
expect(notifier.payNow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain payment window on import trains', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: 'import-train',
|
||||
direction: 'IMPORT',
|
||||
windowClosesAt: closesAt,
|
||||
});
|
||||
|
||||
await service.acceptIntercity(waiting, 'import-train');
|
||||
|
||||
const deadline = (
|
||||
bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date }
|
||||
).paymentDeadline;
|
||||
// 60-minute pay window runs past the 30-minutes-out close: no clamp.
|
||||
expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime());
|
||||
});
|
||||
|
||||
it('rejects an accept after the export window closed — no pay window opens', async () => {
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
direction: 'EXPORT',
|
||||
windowClosesAt: new Date(Date.now() - 60_000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.acceptIntercity(waiting, exportScheduleId),
|
||||
).rejects.toThrow(/window has closed/);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('expires an unpaid export ride-along at close and frees the train', async () => {
|
||||
const lapsed = {
|
||||
...(waiting as unknown as Record<string, unknown>),
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
trainScheduleId: exportScheduleId,
|
||||
paymentDeadline: new Date(Date.now() - 1_000),
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
priorityScore: 0,
|
||||
wagonsRequired: 1,
|
||||
} as unknown as Booking;
|
||||
bookingsRepository.findReservedForSchedule
|
||||
.mockResolvedValueOnce([lapsed])
|
||||
.mockResolvedValue([]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]);
|
||||
// expire()'s paid-guard re-reads the booking fresh — still unpaid.
|
||||
bookingRepo.findOne.mockResolvedValue(lapsed);
|
||||
trainSchedulesRepository.findById.mockResolvedValue({
|
||||
id: exportScheduleId,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'DONE',
|
||||
scheduledDepartureDate: new Date(Date.now() + 3_600_000),
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
});
|
||||
|
||||
await service.settleDueReservations(exportScheduleId);
|
||||
|
||||
expect(notifier.expired).toHaveBeenCalledTimes(1);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'ic-1',
|
||||
expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingBatchService — wagonsFor', () => {
|
||||
|
||||
@@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
// This payment may have been the last live pay window on a now-full
|
||||
// export day — the settle that normally re-runs the sweep finds nothing
|
||||
// left to settle, so trigger it here.
|
||||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||
}
|
||||
|
||||
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
|
||||
@@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`— payment phase extended for them`,
|
||||
);
|
||||
}
|
||||
// The settle may have resolved the last pay window on a full export day
|
||||
// (paid → allocated, and the top-up found nothing else that fits) — sweep
|
||||
// the date's leftover bookings. Self-guarded: no-op for import/domestic
|
||||
// and while any train on the day can still take bookings.
|
||||
await this.expireLeftoverExportDay(scheduleId);
|
||||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||
// this) so one settle produces one push, after every allocation/expiry/
|
||||
// top-up extension for this schedule has been persisted.
|
||||
@@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
// Same as the webhook path: a staff mark-paid can settle the last live
|
||||
// pay window on a now-full export day — sweep the date's leftovers.
|
||||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||
@@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
||||
// train-wide isTrainFull / committedWagons finalize signal.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
||||
collapseForBuiltTrain: true,
|
||||
});
|
||||
// Built trains use the leg-aware corridor budget too: the wagon planner
|
||||
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
|
||||
// that runs empty Gelan→Adama genuinely can carry an intercity booking
|
||||
// there before its export cargo boards at Adama. A train full on one leg
|
||||
// still accepts ride-alongs on its empty legs — that is the whole point
|
||||
// of the ride-along flow.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
@@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
// EXPORT parity: pay windows on an export train never outlive its booking
|
||||
// window — export bookings expire at close, so anything reserved onto the
|
||||
// same train (FCFS export or an intercity ride-along) must too. Import
|
||||
// keeps the plain payment window; its cycles re-fill after settle.
|
||||
const targetSchedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (targetSchedule?.direction === "EXPORT") {
|
||||
const cutoff =
|
||||
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||||
if (cutoff && cutoff.getTime() <= now.getTime()) {
|
||||
throw new BadRequestException(
|
||||
"Export booking window has closed — cannot open a pay window on this train",
|
||||
);
|
||||
}
|
||||
if (cutoff && cutoff.getTime() < deadline.getTime()) {
|
||||
deadline = new Date(cutoff);
|
||||
}
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: "SELECTED_FOR_BATCH",
|
||||
@@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return leftovers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
|
||||
* so nothing ever concluded its day: bookings still waiting when the trains
|
||||
* filled up or the window closed stayed pending forever. Once every export
|
||||
* train on this route-day is shut — window DONE, or FULL with no pay window
|
||||
* still live that could lapse and free space — the date is dead: expire the
|
||||
* un-accepted bookings staff can no longer accept AND the ready
|
||||
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
|
||||
* waiters). Runs at export window close and whenever an export train's
|
||||
* fullness settles.
|
||||
*/
|
||||
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
|
||||
return;
|
||||
}
|
||||
const day = eatDay(schedule.scheduledDepartureDate);
|
||||
const trains = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
})
|
||||
).filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day,
|
||||
);
|
||||
for (const s of trains) {
|
||||
// Any train still taking bookings keeps the date alive.
|
||||
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
|
||||
// A FULL train whose reservations are still inside their pay windows can
|
||||
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
|
||||
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.expireUnacceptedForRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day,
|
||||
});
|
||||
await this.expireLeftoverDayPool(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||
@@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
opts?: { collapseForBuiltTrain?: boolean },
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
@@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
||||
// commits each booking to a wagon for the entire route — it never reloads a
|
||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
||||
// committedWagons already count it. When a caller opts in, collapse the
|
||||
// corridor to a single whole-route edge so every booking (full-route OR
|
||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
||||
// leg it merely passes through, instead of over-promising the freed slots.
|
||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
||||
const stops =
|
||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
||||
? [schedule.originStationId, schedule.destinationStationId]
|
||||
: await this.stopsForSchedule(schedule);
|
||||
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||||
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
|
||||
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
@@ -68,6 +69,9 @@ export class BookingJourneyService {
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.Mock;
|
||||
refreshWindowStatus: jest.Mock;
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
expireLeftoverExportDay: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
@@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||
expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
|
||||
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
@@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
}
|
||||
// Export has no conclude step: this close is the last moment the day's
|
||||
// bookings could have boarded. Once every train on the route-day is
|
||||
// shut, expire what is still waiting for this date (the sweep defers
|
||||
// while a sibling train stays open).
|
||||
await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveWagonLoadDto {
|
||||
/**
|
||||
* Where the source wagon's whole load goes: a train-set wagon slot (empty →
|
||||
* move, loaded → swap the two loads) or an empty consist-only physical wagon
|
||||
* of the built train (→ the slot repins onto it).
|
||||
*/
|
||||
@IsUUID()
|
||||
targetWagonId!: string;
|
||||
}
|
||||
@@ -146,10 +146,8 @@ export class IntercityService {
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
// legForYards, not legOf: on a built train the budget is a single
|
||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
||||
// must draw from that one pool via the whole-route fallback. On a
|
||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
||||
// legForYards: the booking draws only from ITS OWN leg's edges, with a
|
||||
// whole-route fallback when its yards aren't on the budget's stop list.
|
||||
const leg = capacity?.budget.legForYards(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
@@ -215,11 +213,8 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
||||
// reloaded), so the booking draws from that pool via the whole-route
|
||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
||||
// leg, so it can still board a train that is full only on other legs.
|
||||
// legForYards: charge only the edges this booking rides, so it can still
|
||||
// board a train that is full only on other legs.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
|
||||
@@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||
@@ -374,6 +375,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/wagons/:wagonId/move-load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)",
|
||||
})
|
||||
moveWagonLoad(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("wagonId", ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: MoveWagonLoadDto,
|
||||
) {
|
||||
return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/unassigned-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get unassigned bookings for a schedule" })
|
||||
|
||||
@@ -1081,4 +1081,172 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveWagonLoad — staff rearrange', () => {
|
||||
const containerType = {
|
||||
code: 'NX70',
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
supportsContainer: true,
|
||||
};
|
||||
let slotA: Record<string, unknown>;
|
||||
let slotB: Record<string, unknown>;
|
||||
let allocsByWagon: Record<string, Array<Record<string, unknown>>>;
|
||||
let allocRepo: { find: jest.Mock; update: jest.Mock };
|
||||
let slotRepo: { update: jest.Mock };
|
||||
let wagonRepo: { findOne: jest.Mock };
|
||||
|
||||
const makeSchedule = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'sched-1',
|
||||
status: 'SCHEDULED',
|
||||
trainSetId: 'ts-1',
|
||||
trainSet: { trainId: 'train-1', wagons: [slotA, slotB] },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
slotA = {
|
||||
id: 'wA',
|
||||
sequenceNo: 1,
|
||||
capacityTons: 61,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: 40,
|
||||
status: 'RESERVED',
|
||||
boardYardId: 'yard-1',
|
||||
alightYardId: null,
|
||||
wagonType: containerType,
|
||||
};
|
||||
slotB = {
|
||||
id: 'wB',
|
||||
sequenceNo: 2,
|
||||
capacityTons: 61,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: 25,
|
||||
status: 'RESERVED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
wagonType: containerType,
|
||||
};
|
||||
allocsByWagon = {
|
||||
// 20ft pair (two allocations sharing wagon A) — must travel together.
|
||||
wA: [
|
||||
{ id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||
{ id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' },
|
||||
],
|
||||
// one 40ft on wagon B.
|
||||
wB: [
|
||||
{ id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' },
|
||||
],
|
||||
};
|
||||
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule());
|
||||
allocRepo = {
|
||||
find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) =>
|
||||
Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []),
|
||||
),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
slotRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
wagonRepo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||
if (entity === WagonBookingAllocation) return allocRepo;
|
||||
if (entity === TrainSetWagon) return slotRepo;
|
||||
if (entity === Wagon) return wagonRepo;
|
||||
return { find: jest.fn().mockResolvedValue([]) };
|
||||
});
|
||||
dataSource.transaction.mockImplementation(
|
||||
async (fn: (m: unknown) => Promise<void>) =>
|
||||
fn({ getRepository: dataSource.getRepository }),
|
||||
);
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
|
||||
'getTrainScheduleById' as never,
|
||||
)
|
||||
.mockResolvedValue({ id: 'sched-1' } as never);
|
||||
});
|
||||
|
||||
it('rejects moves on a dispatched train', async () => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
|
||||
makeSchedule({ status: 'DISPATCHED' }),
|
||||
);
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s when the target is neither a slot nor a consist wagon of this train', async () => {
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }),
|
||||
).rejects.toThrow(/not part of this schedule/);
|
||||
});
|
||||
|
||||
it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => {
|
||||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' });
|
||||
|
||||
// The 20ft pair moved together onto wagon B…
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' });
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' });
|
||||
// …and the 40ft came back to wagon A.
|
||||
expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' });
|
||||
// Load-coupled slot fields follow their loads.
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wB', {
|
||||
assignedWeightTons: 40,
|
||||
status: 'RESERVED',
|
||||
boardYardId: 'yard-1',
|
||||
alightYardId: null,
|
||||
});
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||
assignedWeightTons: 25,
|
||||
status: 'RESERVED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => {
|
||||
wagonRepo.findOne.mockResolvedValue({
|
||||
id: 'phys-9',
|
||||
wagonTypeId: 'wt-1',
|
||||
wagonNumber: 'WGN-9',
|
||||
wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 },
|
||||
});
|
||||
|
||||
await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' });
|
||||
|
||||
expect(wagonRepo.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }),
|
||||
);
|
||||
// Repin: wagon identity moves onto the slot; allocations stay put.
|
||||
expect(slotRepo.update).toHaveBeenCalledWith('wA', {
|
||||
physicalWagonId: 'phys-9',
|
||||
wagonTypeId: 'wt-1',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
});
|
||||
expect(allocRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a bulk load onto a wagon whose type only supports containers', async () => {
|
||||
allocsByWagon.wA = [
|
||||
{ id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' },
|
||||
];
|
||||
allocsByWagon.wB = [];
|
||||
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(/cannot carry a bulk load/);
|
||||
});
|
||||
|
||||
it('rejects when the incoming load exceeds the receiving wagon payload', async () => {
|
||||
allocsByWagon.wA = [
|
||||
{ id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' },
|
||||
];
|
||||
allocsByWagon.wB = [];
|
||||
|
||||
await expect(
|
||||
service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }),
|
||||
).rejects.toThrow(/over its/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
TrainScheduleFreightType,
|
||||
} from './dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
@@ -122,7 +123,7 @@ import {
|
||||
sumWagonsRequired,
|
||||
type TrainLimitConfig,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimits,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
@@ -1538,8 +1539,21 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
// The rebuild below deletes EVERY schedule↔booking link row and recreates
|
||||
// only what makes the new plan. Ride-along (intercity) bookings are linked
|
||||
// OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
|
||||
// workspace's picked ids, so planning from dto.bookingIds alone silently
|
||||
// orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
|
||||
// every list. Every (re)assignment therefore re-plans the WHOLE train:
|
||||
// the requested ids plus everything currently linked.
|
||||
const linkedRows =
|
||||
await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
|
||||
const allBookingIds = [
|
||||
...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
|
||||
];
|
||||
|
||||
const previewDto = {
|
||||
bookingIds: dto.bookingIds,
|
||||
bookingIds: allBookingIds,
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
originStationId: schedule.originStationId,
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
@@ -1562,8 +1576,13 @@ export class TrainSchedulingService {
|
||||
// preview the wagon plan first, then lay containers into the plan's slots.
|
||||
// Without this the placement validator rejects container bookings outright
|
||||
// ("Container placements are required for container bookings").
|
||||
// Callers hand-pick placements only for the bookings they know about; the
|
||||
// union above may have folded in linked ride-alongs those placements never
|
||||
// covered. Auto-fill whatever units are missing (all of them when no
|
||||
// placements were sent at all) so the placement validator doesn't reject
|
||||
// container bookings the caller couldn't have placed.
|
||||
let containerPlacements = dto.containerPlacements;
|
||||
if (!containerPlacements?.length) {
|
||||
{
|
||||
const preview = await this.validateBookingsForScheduling(
|
||||
previewDto,
|
||||
freightType ?? null,
|
||||
@@ -1578,18 +1597,28 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (containerBookings.length) {
|
||||
const units = expandBookingContainerUnits(containerBookings);
|
||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||
const generated = autoFillPlacements(units, slots);
|
||||
const missing = findMissingContainerNumberIssues(units, generated);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException({
|
||||
message: `Booking validation failed: ${missing
|
||||
.map((m) => m.issue)
|
||||
.join('; ')}`,
|
||||
violations: missing.map((m) => m.issue),
|
||||
});
|
||||
const providedKeys = new Set(
|
||||
(containerPlacements ?? []).map(
|
||||
(p) => `${p.bookingContainerId}:${p.unitIndex}`,
|
||||
),
|
||||
);
|
||||
const unplacedUnits = units.filter(
|
||||
(u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
|
||||
);
|
||||
if (unplacedUnits.length) {
|
||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||
const generated = autoFillPlacements(unplacedUnits, slots);
|
||||
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException({
|
||||
message: `Booking validation failed: ${missing
|
||||
.map((m) => m.issue)
|
||||
.join('; ')}`,
|
||||
violations: missing.map((m) => m.issue),
|
||||
});
|
||||
}
|
||||
containerPlacements = [...(containerPlacements ?? []), ...generated];
|
||||
}
|
||||
containerPlacements = generated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,8 +1661,10 @@ export class TrainSchedulingService {
|
||||
// NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
|
||||
// A stock shortage is a physical impossibility, so forceAssign cannot
|
||||
// override it either.
|
||||
// Linked ride-alongs count as requested too: silently dropping one here is
|
||||
// exactly the delete-and-recreate orphan this method must never produce.
|
||||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||
const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id));
|
||||
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
||||
if (droppedRequested.length) {
|
||||
const reasonById = new Map(
|
||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||
@@ -2632,6 +2663,11 @@ export class TrainSchedulingService {
|
||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.size) {
|
||||
// Export cargo must be received at the warehouse with a GRN before it can
|
||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||
if (this.isExportSchedule(schedule)) {
|
||||
await this.assertExportBookingsReceived([...wagonAssignedIds]);
|
||||
}
|
||||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||||
scheduleId,
|
||||
[...wagonAssignedIds],
|
||||
@@ -2909,6 +2945,38 @@ export class TrainSchedulingService {
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every export booking being confirmed loaded must already be received at the
|
||||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||
*/
|
||||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||
if (!bookingIds.length) return;
|
||||
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||
`SELECT b.reference
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = ANY($1)
|
||||
AND b.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = b.id
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
)`,
|
||||
[bookingIds],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
|
||||
throw new BadRequestException(
|
||||
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -3815,25 +3883,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
|
||||
// stop list is also what makes the wagon plan leg-aware below.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
}
|
||||
if (
|
||||
await (async () => {
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
return bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
});
|
||||
})()
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
})
|
||||
) {
|
||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||
}
|
||||
@@ -3919,7 +3986,22 @@ export class TrainSchedulingService {
|
||||
stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
|
||||
}
|
||||
|
||||
const planned = planWagonsWithStock({ bookings, allowed, stock });
|
||||
// Leg-aware stock: each booking consumes wagons only on the edges it rides,
|
||||
// so a ride-along on an empty leg never competes with cargo on a full one.
|
||||
const legByBookingId = new Map(
|
||||
bookings.flatMap((b) => {
|
||||
const from = stops.indexOf(b.originYardId);
|
||||
const to = stops.indexOf(b.destinationYardId);
|
||||
return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
|
||||
}),
|
||||
);
|
||||
const planned = planWagonsWithStock({
|
||||
bookings,
|
||||
allowed,
|
||||
stock,
|
||||
legs: legByBookingId,
|
||||
edgeCount: Math.max(1, stops.length - 1),
|
||||
});
|
||||
violations.push(...planned.configIssues);
|
||||
const fittingBookings = planned.fitting;
|
||||
const deferredBookings: DeferredBookingRow[] = planned.deferred;
|
||||
@@ -3981,10 +4063,11 @@ export class TrainSchedulingService {
|
||||
).values(),
|
||||
];
|
||||
pushLimit(
|
||||
validateMixedTrainLimits(
|
||||
validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan,
|
||||
plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
|
||||
trainLimits,
|
||||
stops,
|
||||
),
|
||||
);
|
||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||
@@ -6773,12 +6856,33 @@ export class TrainSchedulingService {
|
||||
status: sb.booking?.status ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
// Which leg of the corridor this booking rides — the workspace can't
|
||||
// tell a ride-along (intercity) or sub-corridor booking from through
|
||||
// cargo without it.
|
||||
tradeDirection: sb.booking?.tradeDirection ?? null,
|
||||
originYardId: sb.booking?.originYardId ?? null,
|
||||
destinationYardId: sb.booking?.destinationYardId ?? null,
|
||||
origin:
|
||||
sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
|
||||
destination:
|
||||
sb.booking?.destinationYard?.label ??
|
||||
sb.booking?.destinationYard?.code ??
|
||||
null,
|
||||
wagonsRequired:
|
||||
sb.booking?.wagonsRequired != null
|
||||
? Number(sb.booking.wagonsRequired)
|
||||
: null,
|
||||
loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
|
||||
arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
|
||||
// Loaded/unloaded is tracked on the schedule↔booking link, not the
|
||||
// booking itself — staff flip it per booking in the workspace before
|
||||
// dispatch. Defaults UNLOADED for links written before the column.
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||
})) ?? [],
|
||||
// Ordered corridor stops (route milestones; falls back to the two
|
||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
||||
stops: this.mapScheduleStops(schedule),
|
||||
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||
// it "historical" and skip re-pin affordances.
|
||||
@@ -6787,6 +6891,42 @@ export class TrainSchedulingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||
private mapScheduleStops(
|
||||
schedule: TrainSchedule,
|
||||
): Array<{ yardId: string; label: string }> {
|
||||
const milestones = [...(schedule.route?.milestones ?? [])].sort(
|
||||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
const raw = milestones.length >= 2
|
||||
? milestones.map((m) => ({
|
||||
yardId: m.yardId,
|
||||
label: m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
yardId: schedule.originStationId,
|
||||
label:
|
||||
schedule.originStation?.label ??
|
||||
schedule.originStation?.code ??
|
||||
schedule.originStationId,
|
||||
},
|
||||
{
|
||||
yardId: schedule.destinationStationId,
|
||||
label:
|
||||
schedule.destinationStation?.label ??
|
||||
schedule.destinationStation?.code ??
|
||||
schedule.destinationStationId,
|
||||
},
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return raw.filter((stop) => {
|
||||
if (!stop.yardId || seen.has(stop.yardId)) return false;
|
||||
seen.add(stop.yardId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private isHoldActive(booking: Booking): boolean {
|
||||
if (!booking.holdExpiresAt) return false;
|
||||
return booking.holdExpiresAt.getTime() > Date.now();
|
||||
@@ -7195,6 +7335,171 @@ export class TrainSchedulingService {
|
||||
return { id: itemId, containerNumber: dto.containerNumber ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
|
||||
* a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
|
||||
* Whole-load moves keep every packing rule intact by construction (a valid
|
||||
* load stays valid on any wagon whose type supports it), which is what lets
|
||||
* a 20ft pair travel together and swap places with a 40ft, and lets bulk
|
||||
* swap with containers.
|
||||
*
|
||||
* Three shapes, picked from the target:
|
||||
* - target is an empty consist-only wagon (coupled on the built train, no
|
||||
* slot row): REPIN — the source slot simply points at that physical wagon
|
||||
* (type/capacity/length follow), and the wagon it left shows as empty.
|
||||
* - target is an empty slot: allocations repoint to it and the load-coupled
|
||||
* slot fields (assigned weight, status, board/alight leg) move across.
|
||||
* - target is a loaded slot: the two loads swap wagons the same way.
|
||||
*
|
||||
* Validated per direction: the receiving wagon's type must support the
|
||||
* incoming load type, and the incoming cargo must fit its rated payload.
|
||||
*/
|
||||
async moveWagonLoad(
|
||||
scheduleId: string,
|
||||
sourceWagonId: string,
|
||||
dto: MoveWagonLoadDto,
|
||||
): Promise<any> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
|
||||
throw new BadRequestException('Cannot rearrange loads on a dispatched train');
|
||||
}
|
||||
if (sourceWagonId === dto.targetWagonId) {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
const source = slots.find((w) => w.id === sourceWagonId);
|
||||
if (!source) {
|
||||
throw new NotFoundException('Source wagon is not part of this schedule');
|
||||
}
|
||||
|
||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
const loadAllocations = (trainSetWagonId: string) =>
|
||||
allocRepo.find({ where: { trainSetWagonId } });
|
||||
const sourceAllocs = await loadAllocations(source.id);
|
||||
if (!sourceAllocs.length) {
|
||||
throw new BadRequestException('Source wagon has no load to move');
|
||||
}
|
||||
|
||||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||||
// built train (physical wagon with no slot row yet).
|
||||
const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||
const consistWagon = targetSlot
|
||||
? null
|
||||
: schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
if (!targetSlot && !consistWagon) {
|
||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||
}
|
||||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||
|
||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||
];
|
||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||
capacityTons: number,
|
||||
) => {
|
||||
const incoming = loadTypesOf(allocs);
|
||||
// Unknown type or no declared support list → staff decides; don't block.
|
||||
if (wagonType) {
|
||||
const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
|
||||
for (const loadType of incoming) {
|
||||
const ok =
|
||||
supported.includes(loadType) ||
|
||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||
supported.length === 0;
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const cargo = cargoOf(allocs);
|
||||
if (capacityTons > 0 && cargo > capacityTons + 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// What the target must be able to receive…
|
||||
checkReceives(
|
||||
sourceAllocs,
|
||||
wagonLabel(targetSlot, consistWagon),
|
||||
targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
|
||||
Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
|
||||
);
|
||||
// …and, on a swap, what comes back to the source.
|
||||
if (targetAllocs.length) {
|
||||
checkReceives(
|
||||
targetAllocs,
|
||||
`#${source.sequenceNo}`,
|
||||
source.wagonType,
|
||||
Number(source.capacityTons),
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const slotRepo = manager.getRepository(TrainSetWagon);
|
||||
const allocs = manager.getRepository(WagonBookingAllocation);
|
||||
|
||||
// Empty consist wagon: repin the loaded slot onto that physical wagon.
|
||||
// Allocations and load fields stay put; only the wagon identity changes.
|
||||
if (consistWagon) {
|
||||
await slotRepo.update(source.id, {
|
||||
physicalWagonId: consistWagon.id,
|
||||
wagonTypeId: consistWagon.wagonTypeId,
|
||||
capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)),
|
||||
lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetSlot as TrainSetWagon;
|
||||
// Load-coupled slot fields travel with the load; wagon identity stays.
|
||||
const loadFieldsOf = (slot: TrainSetWagon) => ({
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: slot.status,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
});
|
||||
const emptyLoadFields = {
|
||||
assignedWeightTons: 0,
|
||||
status: 'PLANNED',
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
};
|
||||
const sourceLoadFields = loadFieldsOf(source);
|
||||
const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;
|
||||
|
||||
for (const alloc of sourceAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: target.id });
|
||||
}
|
||||
for (const alloc of targetAllocs) {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: source.id });
|
||||
}
|
||||
await slotRepo.update(target.id, sourceLoadFields);
|
||||
await slotRepo.update(source.id, targetLoadFields);
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
|
||||
@@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => {
|
||||
expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => {
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map([['ct-1', [nw6]]]),
|
||||
byCargoTypeId: new Map(),
|
||||
};
|
||||
// Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1.
|
||||
const legs = (entries: Array<[string, { from: number; to: number }]>) =>
|
||||
new Map(entries);
|
||||
|
||||
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
|
||||
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
['EXPORT-1', { from: 1, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.fitting.map((b) => b.id).sort()).toEqual([
|
||||
'EXPORT-1',
|
||||
'INTERCITY-1',
|
||||
]);
|
||||
// Two slots planned, but both drawn from the single physical wagon.
|
||||
expect(result.plan).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still defers when the legs overlap and stock is exhausted', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
// Both ride edge 0 — they compete for the one wagon.
|
||||
['EXPORT-1', { from: 0, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']);
|
||||
expect(result.deferred).toHaveLength(1);
|
||||
expect(result.deferred[0]!.reference).toBe('INTERCITY-1');
|
||||
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
|
||||
});
|
||||
|
||||
it('never packs bookings with different legs into the same wagon slot', () => {
|
||||
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
|
||||
// must open separate slots (each with its own leg), not one mixed slot.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 2]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
legs: legs([
|
||||
['EXPORT-1', { from: 1, to: 2 }],
|
||||
['INTERCITY-1', { from: 0, to: 1 }],
|
||||
]),
|
||||
edgeCount: 2,
|
||||
});
|
||||
|
||||
expect(result.plan).toHaveLength(2);
|
||||
const bookingsPerSlot = result.plan.map((s) =>
|
||||
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
|
||||
);
|
||||
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
|
||||
});
|
||||
|
||||
it('behaves exactly like the whole-route planner when no legs are given', () => {
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [
|
||||
containerBooking('EXPORT-1', 1, 1),
|
||||
containerBooking('INTERCITY-1', 1, 1),
|
||||
],
|
||||
allowed,
|
||||
stock: {
|
||||
mode: 'TRAIN',
|
||||
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||
},
|
||||
});
|
||||
|
||||
// One wagon, two 20ft bookings: they TEU-share the single slot (legacy).
|
||||
expect(result.deferred).toHaveLength(0);
|
||||
expect(result.plan).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,18 @@ type OpenSlot = {
|
||||
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
|
||||
cargoTypeId: string | null;
|
||||
freeCapacityTons: number;
|
||||
/**
|
||||
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
|
||||
* share a slot when their legs are identical — mixing corridors in one slot
|
||||
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
|
||||
* re-occupy edges the cargo never rides.
|
||||
*/
|
||||
legKey: string;
|
||||
};
|
||||
|
||||
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||
export type BookingLeg = { from: number; to: number };
|
||||
|
||||
type PlacementProblem = {
|
||||
kind: 'config' | 'stock';
|
||||
message: string;
|
||||
@@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS
|
||||
const shortageFor = (
|
||||
booking: Booking,
|
||||
candidates: WagonType[],
|
||||
remaining: Map<string, number>,
|
||||
availableOf: (wagonTypeId: string) => number,
|
||||
): BookingWagonShortage => {
|
||||
const wagonsNeeded =
|
||||
booking.freightType === 'BULK'
|
||||
@@ -100,7 +110,7 @@ const shortageFor = (
|
||||
)
|
||||
: Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
const wagonsAvailable = candidates.reduce(
|
||||
(sum, wt) => sum + (remaining.get(wt.id) ?? 0),
|
||||
(sum, wt) => sum + availableOf(wt.id),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
@@ -140,14 +150,53 @@ export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
allowed: AllowedWagonTypeMap;
|
||||
stock: WagonStock;
|
||||
/**
|
||||
* Leg-aware stock: booking id → the stop-index range it rides. When given
|
||||
* (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so
|
||||
* the same physical wagon can serve an intercity booking on Gelan→Adama and
|
||||
* an export booking on Adama→Doraleh — disjoint legs never compete for
|
||||
* stock. Omitted → one edge, byte-identical to the old whole-route behavior.
|
||||
*/
|
||||
legs?: Map<string, BookingLeg>;
|
||||
edgeCount?: number;
|
||||
}): FlexPlanResult {
|
||||
const { bookings, allowed, stock } = params;
|
||||
const remaining = new Map(stock.remainingByTypeId);
|
||||
const { bookings, allowed, stock, legs } = params;
|
||||
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||
const openSlots: OpenSlot[] = [];
|
||||
const fitting: Booking[] = [];
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
const legFor = (booking: Booking): BookingLeg => {
|
||||
const leg = legs?.get(booking.id);
|
||||
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||
return { from: 0, to: edgeCount };
|
||||
}
|
||||
return leg;
|
||||
};
|
||||
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||||
|
||||
// Wagons of a type in use per corridor edge. A type is available for a leg
|
||||
// when its busiest edge WITHIN that leg still has stock spare — the max over
|
||||
// edges is the number of physical wagons the type needs simultaneously.
|
||||
const usedPerEdge = new Map<string, number[]>();
|
||||
const usedRow = (wagonTypeId: string): number[] => {
|
||||
let row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) {
|
||||
row = new Array<number>(edgeCount).fill(0);
|
||||
usedPerEdge.set(wagonTypeId, row);
|
||||
}
|
||||
return row;
|
||||
};
|
||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||||
const row = usedPerEdge.get(wagonTypeId);
|
||||
if (!row) return total;
|
||||
let busiest = 0;
|
||||
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
||||
return total - busiest;
|
||||
};
|
||||
|
||||
const noStockMessage = (candidates: WagonType[]): string => {
|
||||
const codes = candidates.map((wt) => wt.code).join('/');
|
||||
return stock.mode === 'TRAIN'
|
||||
@@ -155,13 +204,14 @@ export function planWagonsWithStock(params: {
|
||||
: `No available ${codes} wagon at the yard`;
|
||||
};
|
||||
|
||||
/** Open a new wagon of one of the candidate types, consuming stock. */
|
||||
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||
const openSlot = (
|
||||
candidates: WagonType[],
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
leg: BookingLeg,
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0);
|
||||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
||||
}
|
||||
@@ -170,22 +220,26 @@ export function planWagonsWithStock(params: {
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
(remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0)
|
||||
: (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0),
|
||||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||
)[0];
|
||||
remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1);
|
||||
const row = usedRow(chosen.id);
|
||||
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||
const open: OpenSlot = {
|
||||
slot: slotFromWagonType(chosen, kind),
|
||||
teuUsed: 0,
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
legKey: legKeyOf(leg),
|
||||
};
|
||||
openSlots.push(open);
|
||||
return open;
|
||||
};
|
||||
|
||||
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
|
||||
const leg = legFor(booking);
|
||||
const legKey = legKeyOf(leg);
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const units = expandBookingContainerUnits([booking]);
|
||||
if (!units.length) {
|
||||
@@ -209,11 +263,12 @@ export function planWagonsWithStock(params: {
|
||||
let target = openSlots.find(
|
||||
(open) =>
|
||||
open.kind === 'CONTAINER' &&
|
||||
open.legKey === legKey &&
|
||||
allowedIds.has(open.slot.wagonTypeId) &&
|
||||
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
|
||||
);
|
||||
if (!target) {
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null);
|
||||
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
target = openedSlot;
|
||||
}
|
||||
@@ -246,6 +301,7 @@ export function planWagonsWithStock(params: {
|
||||
for (const open of openSlots) {
|
||||
if (remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
@@ -263,7 +319,7 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
while (remainingWeight > 0 || !placedAnywhere) {
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId);
|
||||
const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
@@ -282,7 +338,9 @@ export function planWagonsWithStock(params: {
|
||||
|
||||
for (const booking of sortBookingsForScheduling(bookings)) {
|
||||
// Snapshot so a booking that doesn't fully fit leaves no half-placed wagons.
|
||||
const remainingSnapshot = new Map(remaining);
|
||||
const usedSnapshot = new Map(
|
||||
[...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]),
|
||||
);
|
||||
const slotCountSnapshot = openSlots.length;
|
||||
const slotStateSnapshot = openSlots.map((open) => ({
|
||||
teuUsed: open.teuUsed,
|
||||
@@ -299,8 +357,8 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
|
||||
// Roll back this booking's partial placements.
|
||||
remaining.clear();
|
||||
for (const [key, value] of remainingSnapshot) remaining.set(key, value);
|
||||
usedPerEdge.clear();
|
||||
for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value);
|
||||
openSlots.length = slotCountSnapshot;
|
||||
openSlots.forEach((open, index) => {
|
||||
const snap = slotStateSnapshot[index];
|
||||
@@ -315,11 +373,14 @@ export function planWagonsWithStock(params: {
|
||||
});
|
||||
|
||||
if (problem.kind === 'config') configIssues.add(problem.message);
|
||||
// remaining is rolled back here, so the shortage counts the stock this
|
||||
// Usage is rolled back here, so the shortage counts the stock this
|
||||
// booking actually saw — not what its own partial placement consumed.
|
||||
const bookingLeg = legFor(booking);
|
||||
const shortage =
|
||||
problem.kind === 'stock' && problem.candidates?.length
|
||||
? shortageFor(booking, problem.candidates, remaining)
|
||||
? shortageFor(booking, problem.candidates, (wagonTypeId) =>
|
||||
Math.max(0, availableFor(wagonTypeId, bookingLeg)),
|
||||
)
|
||||
: null;
|
||||
deferred.push({
|
||||
id: booking.id,
|
||||
|
||||
@@ -525,6 +525,40 @@ export function validateMixedTrainLimits(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leg-aware limit check: with a real stop list, a slot only counts on the
|
||||
* edges it actually rides (boardYardId→alightYardId; null = the schedule's
|
||||
* own endpoint). Each edge is validated as its own consist, so an intercity
|
||||
* wagon on Gelan→Adama never counts against a train that is full only on
|
||||
* Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check.
|
||||
*/
|
||||
export function validateMixedTrainLimitsPerEdge(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits: TrainLimitConfig | undefined,
|
||||
stops: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const lastIdx = stops.length - 1;
|
||||
const spans = wagonPlan.map((slot) => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx;
|
||||
// A yard missing from the stop list keeps the slot on the whole route.
|
||||
return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx };
|
||||
});
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < lastIdx; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||
violations.add(violation);
|
||||
}
|
||||
}
|
||||
return [...violations];
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
units: ContainerUnitRow[],
|
||||
placements: ContainerPlacementInput[],
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
|
||||
const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100;
|
||||
|
||||
/** Locomotive statuses that block a train from reactivating. */
|
||||
const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']);
|
||||
|
||||
/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */
|
||||
export interface ActiveScheduleRef {
|
||||
id: string;
|
||||
@@ -596,11 +599,30 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
|
||||
/**
|
||||
* Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled
|
||||
* again. Blocked if any coupled locomotive is unfit for service — a
|
||||
* deactivated train can sit parked for a while and its locomotives may have
|
||||
* since been sent to maintenance independently; reactivating must not wave
|
||||
* a down locomotive back onto the schedule board.
|
||||
*/
|
||||
async activate(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.Deactivated) {
|
||||
const links = await this.dataSource
|
||||
.getRepository(TrainLocomotive)
|
||||
.find({ where: { trainId: id }, relations: { locomotive: true } });
|
||||
const unfit = links
|
||||
.map((link) => link.locomotive)
|
||||
.filter((loco): loco is Locomotive => Boolean(loco))
|
||||
.filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status));
|
||||
if (unfit.length) {
|
||||
const names = unfit.map((l) => `${l.code} (${l.status})`).join(', ');
|
||||
throw new ConflictException(
|
||||
`Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`,
|
||||
);
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(Train)
|
||||
.update(id, { status: Freight.TrainStatus.Available });
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { CreateVehicleDto } from './create-vehicle.dto';
|
||||
|
||||
const base = {
|
||||
vehicleType: 'TRUCK',
|
||||
manufacturer: 'IVECO',
|
||||
model: 'HYT',
|
||||
year: 2020,
|
||||
fuelType: 'DIESEL',
|
||||
capacity: 0,
|
||||
status: 'ACTIVE',
|
||||
};
|
||||
|
||||
const errorsFor = (over: Record<string, unknown>) =>
|
||||
validate(plainToInstance(CreateVehicleDto, { ...base, ...over }));
|
||||
|
||||
const plateErrors = (
|
||||
errors: Awaited<ReturnType<typeof errorsFor>>,
|
||||
property: string,
|
||||
) => errors.find((e) => e.property === property && e.constraints?.matches);
|
||||
|
||||
describe('CreateVehicleDto — plate format', () => {
|
||||
it('accepts a plate like ET-9875', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a plate like AA-8642', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('upper-cases a lower-case plate before validating', async () => {
|
||||
const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' });
|
||||
expect(dto.plateNumber).toBe('ET-9875');
|
||||
expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a free-text plate like assadasd', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a plate with no letters or no digits', async () => {
|
||||
expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined();
|
||||
expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects a bad trailer plate but allows a valid one', async () => {
|
||||
expect(
|
||||
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows an empty trailer plate (optional)', async () => {
|
||||
const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' });
|
||||
expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,30 @@
|
||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
|
||||
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
|
||||
|
||||
/**
|
||||
* A vehicle plate is two or three letters, a hyphen, then two to six digits —
|
||||
* e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer
|
||||
* all match and the message stays consistent.
|
||||
*/
|
||||
export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/;
|
||||
export const VEHICLE_PLATE_MESSAGE =
|
||||
'must be letters and numbers like ET-9875 or AA-8642';
|
||||
|
||||
/**
|
||||
* Trim and upper-case a plate before validating, so "et-9875" is accepted. An
|
||||
* empty optional plate (trailer/power) becomes undefined so @IsOptional skips it
|
||||
* rather than failing the pattern.
|
||||
*/
|
||||
const normalizePlate = ({ value }: { value: unknown }) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim().toUpperCase();
|
||||
return trimmed === '' ? undefined : trimmed;
|
||||
};
|
||||
|
||||
export class CreateVehicleDto {
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
|
||||
@@ -47,10 +70,14 @@ export class CreateVehicleDto {
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
powerPlateNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(normalizePlate)
|
||||
@Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` })
|
||||
@IsString()
|
||||
trailerPlateNo?: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
|
||||
import { VehiclesService } from './vehicles.service';
|
||||
|
||||
// One driver ⇒ one truck: create/update must refuse a driver already assigned
|
||||
// to another (non-deleted) vehicle until they are detached.
|
||||
describe('VehiclesService driver assignment guard', () => {
|
||||
const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' };
|
||||
|
||||
const makeService = (findOne: jest.Mock) =>
|
||||
new VehiclesService(
|
||||
{ findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any,
|
||||
{ record: jest.fn() } as any,
|
||||
);
|
||||
|
||||
it('rejects create when the driver is on another truck', async () => {
|
||||
// First findOne = plate uniqueness (null), second = driver holder.
|
||||
const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck);
|
||||
const svc = makeService(findOne);
|
||||
await expect(
|
||||
svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects update when reassigning a driver still attached elsewhere', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById
|
||||
.mockResolvedValueOnce(otherTruck); // driver holder
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows update that keeps the same driver on the same truck', async () => {
|
||||
const findOne = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' });
|
||||
const svc = makeService(findOne);
|
||||
await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined();
|
||||
expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,25 @@ export class VehiclesService {
|
||||
private readonly history: FleetHistoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* A driver holds one truck at a time — reassignment requires detaching them
|
||||
* from their current truck first.
|
||||
* ponytail: app-level guard only (race window); add a partial unique index on
|
||||
* assigned_driver_id if concurrent fleet edits ever become real.
|
||||
*/
|
||||
private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise<void> {
|
||||
const holder = await this.vehicleRepo.findOne({
|
||||
where: exceptVehicleId
|
||||
? { assignedDriverId: driverId, id: Not(exceptVehicleId) }
|
||||
: { assignedDriverId: driverId },
|
||||
});
|
||||
if (holder) {
|
||||
throw new ConflictException(
|
||||
`This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateVehicleDto): Promise<Vehicle> {
|
||||
const existing = await this.vehicleRepo.findOne({
|
||||
where: { plateNumber: dto.plateNumber },
|
||||
@@ -31,6 +50,10 @@ export class VehiclesService {
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId);
|
||||
}
|
||||
|
||||
const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`;
|
||||
const vehicle = this.vehicleRepo.create({
|
||||
...dto,
|
||||
@@ -121,6 +144,10 @@ export class VehiclesService {
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) {
|
||||
await this.assertDriverUnassigned(dto.assignedDriverId, id);
|
||||
}
|
||||
|
||||
const prev = {
|
||||
assignedDriverId: vehicle.assignedDriverId,
|
||||
assignedDriverName: vehicle.assignedDriverName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
@@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dateTo?: string;
|
||||
|
||||
// ── KPI drill-down filters ──────────────────────────────────────────────
|
||||
// Each mirrors one opsStats() counter so a dashboard card's count always
|
||||
// equals the length of the list it opens.
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||
@IsBoolean()
|
||||
receivedToday?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1'))
|
||||
@IsBoolean()
|
||||
pendingInspection?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
agingOverDays?: number;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
|
||||
* One import handover. A booking has a single handover when one truck takes the
|
||||
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
|
||||
* multiple trucks are used. Self-haul handovers are generated on truck arrival
|
||||
* and signed before the truck leaves; EDR last-mile handovers are generated at
|
||||
* delivery (after exit).
|
||||
* and signed before the truck leaves; EDR last-mile handovers are generated
|
||||
* when the EDR truck exits the warehouse (with its exit paper) and signed by
|
||||
* the customer in the portal on delivery — one signature per truck.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_handovers' })
|
||||
@Index(['bookingId'])
|
||||
@@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity {
|
||||
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
|
||||
truckAssignmentId?: string | null;
|
||||
|
||||
/** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */
|
||||
@Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true })
|
||||
edrAssignmentId?: string | null;
|
||||
|
||||
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
|
||||
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
|
||||
truckPlate?: string | null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
// Exercises the per-truck [Exit Inspection] block helpers directly (no DI).
|
||||
const svc = Object.create(WarehouseInventoryService.prototype) as any;
|
||||
|
||||
const arrivalA =
|
||||
'[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t';
|
||||
const arrivalB =
|
||||
'[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED';
|
||||
|
||||
describe('per-truck exit inspection blocks', () => {
|
||||
it('keeps truck A intact when truck B arrives', () => {
|
||||
const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858');
|
||||
const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562');
|
||||
expect(afterB).toContain('Abebe Lemeno');
|
||||
expect(afterB).toContain('Suleman Tamrat');
|
||||
expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||
expect(afterB.startsWith('Receive note')).toBe(true);
|
||||
});
|
||||
|
||||
it("exit for truck A updates only A's block and preserves arrival data", () => {
|
||||
const notes = svc.replaceExitInspectionNote(
|
||||
svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'),
|
||||
arrivalB,
|
||||
'3-85957/48562',
|
||||
);
|
||||
const dto = svc.preserveTruckArrivalForExit(
|
||||
{ truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' },
|
||||
notes,
|
||||
);
|
||||
expect(dto.driverName).toBe('Abebe Lemeno');
|
||||
expect(dto.tareWeight).toBe(12);
|
||||
expect(dto.weighingSkipped).toBeUndefined();
|
||||
const exitNote = svc.buildExitInspectionNote(dto);
|
||||
const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber);
|
||||
expect(replaced).toContain('Gross Weight: 40 t');
|
||||
expect(replaced).toContain('Net Weight: 28 t');
|
||||
expect(replaced).toContain('Suleman Tamrat'); // B untouched
|
||||
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('skipped weighing records the container-derived net in the note', () => {
|
||||
const dto = {
|
||||
truckPlateNumber: '3-85957/48562',
|
||||
driverName: 'Suleman Tamrat',
|
||||
weighingSkipped: true,
|
||||
netWeight: 27.5,
|
||||
gateInTime: '2026-07-21T09:00:00.000Z',
|
||||
gateOutTime: '2026-07-21T13:00:00.000Z',
|
||||
};
|
||||
const note = svc.buildExitInspectionNote(dto);
|
||||
expect(note).toContain('Weighing: SKIPPED');
|
||||
expect(note).toContain('Net Weight: 27.5 t');
|
||||
});
|
||||
|
||||
it('matches a legacy comma-joined plate list and keeps foreign notes', () => {
|
||||
const legacy =
|
||||
'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}';
|
||||
const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858');
|
||||
expect(block).toContain('Abebe Lemeno');
|
||||
const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858');
|
||||
expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1);
|
||||
expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}');
|
||||
expect(replaced).toContain('Receive note');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
import { DataSource, EntityManager, IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { BookingHandover } from './entities/booking-handover.entity';
|
||||
import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
@@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
|
||||
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
|
||||
* - EDR_LAST_MILE: generated at delivery (after exit).
|
||||
* - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its
|
||||
* exit paper), signed by the customer in the portal per truck; once every
|
||||
* handover is signed the delivery auto-completes (inventory / cargo /
|
||||
* booking → delivered).
|
||||
*/
|
||||
@Injectable()
|
||||
export class HandoverService {
|
||||
@@ -25,14 +28,22 @@ export class HandoverService {
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): Promise<void> {
|
||||
private async notifySignNeeded(
|
||||
bookingId: string,
|
||||
reference: string,
|
||||
opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!b?.companyId) return;
|
||||
const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : '';
|
||||
const body =
|
||||
opts.mileType === 'EDR_LAST_MILE'
|
||||
? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.`
|
||||
: `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
@@ -117,31 +128,98 @@ export class HandoverService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
|
||||
* truck (by plate) or per booking. Idempotent by (booking, plate).
|
||||
*/
|
||||
async ensureAtDelivery(
|
||||
/** Find an existing EDR handover by assignment, else by plate, else booking-level. */
|
||||
private async findEdrHandover(
|
||||
repo: Repository<BookingHandover>,
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
): Promise<BookingHandover | null> {
|
||||
if (opts.edrAssignmentId) {
|
||||
const byAssignment = await repo.findOne({
|
||||
where: { bookingId, edrAssignmentId: opts.edrAssignmentId },
|
||||
});
|
||||
if (byAssignment) return byAssignment;
|
||||
}
|
||||
if (opts.truckPlate) {
|
||||
return repo.findOne({
|
||||
where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate },
|
||||
});
|
||||
}
|
||||
return repo.findOne({
|
||||
where: {
|
||||
bookingId,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: IsNull(),
|
||||
edrAssignmentId: IsNull(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: generate the handover when the EDR truck exits the warehouse
|
||||
* (alongside its exit paper) and ask the customer to sign it from the portal.
|
||||
* One per truck (multiple trucks ⇒ one each) or booking-level when the truck
|
||||
* cannot be resolved. Idempotent by (booking, assignment) / (booking, plate).
|
||||
*/
|
||||
async ensureForDepartedEdrTruck(
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
manager?: EntityManager,
|
||||
): Promise<BookingHandover> {
|
||||
const m = manager ?? this.dataSource.manager;
|
||||
const repo = m.getRepository(BookingHandover);
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
bookingId,
|
||||
truckPlate: opts.truckPlate ?? IsNull(),
|
||||
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
|
||||
},
|
||||
});
|
||||
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||
if (existing) return existing;
|
||||
|
||||
const reference = await this.generateReference(bookingId, m);
|
||||
return repo.save(
|
||||
const saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
truckAssignmentId: opts.truckAssignmentId ?? null,
|
||||
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||
truckPlate: opts.truckPlate ?? null,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
reference,
|
||||
generatedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
this.logger.log(
|
||||
`EDR handover ${reference} generated on truck exit for booking ${bookingId}` +
|
||||
(opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''),
|
||||
);
|
||||
void this.notifySignNeeded(bookingId, reference, {
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: opts.truckPlate,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile: ensure a handover exists at delivery and stamp delivered_at.
|
||||
* Normally the handover was already generated on truck exit — this only fills
|
||||
* the delivery timestamp; a handover is created here only for legacy flows
|
||||
* where the exit was recorded before this feature existed.
|
||||
*/
|
||||
async ensureAtDelivery(
|
||||
bookingId: string,
|
||||
opts: { truckPlate?: string | null; edrAssignmentId?: string | null },
|
||||
manager?: EntityManager,
|
||||
): Promise<BookingHandover> {
|
||||
const m = manager ?? this.dataSource.manager;
|
||||
const repo = m.getRepository(BookingHandover);
|
||||
const existing = await this.findEdrHandover(repo, bookingId, opts);
|
||||
if (existing) {
|
||||
if (!existing.deliveredAt) {
|
||||
existing.deliveredAt = new Date();
|
||||
await repo.save(existing);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
const reference = await this.generateReference(bookingId, m);
|
||||
const saved = await repo.save(
|
||||
repo.create({
|
||||
bookingId,
|
||||
edrAssignmentId: opts.edrAssignmentId ?? null,
|
||||
truckPlate: opts.truckPlate ?? null,
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
reference,
|
||||
@@ -149,6 +227,25 @@ export class HandoverService {
|
||||
deliveredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
void this.notifySignNeeded(bookingId, reference, {
|
||||
mileType: 'EDR_LAST_MILE',
|
||||
truckPlate: opts.truckPlate,
|
||||
});
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Re-send the sign notification for every unsigned handover on the booking. */
|
||||
async notifyUnsignedForBooking(bookingId: string): Promise<void> {
|
||||
const unsigned = await this.dataSource.getRepository(BookingHandover).find({
|
||||
where: { bookingId, signedAt: IsNull() },
|
||||
order: { generatedAt: 'ASC' },
|
||||
});
|
||||
for (const h of unsigned) {
|
||||
await this.notifySignNeeded(bookingId, h.reference, {
|
||||
mileType: h.mileType,
|
||||
truckPlate: h.truckPlate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +279,27 @@ export class HandoverService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one handover (EDR last-mile: the customer signs per truck). Returns the
|
||||
* fresh handover; idempotent — an already-signed handover is returned as-is.
|
||||
*/
|
||||
async sign(
|
||||
handoverId: string,
|
||||
userId?: string | null,
|
||||
signerName?: string | null,
|
||||
): Promise<BookingHandover> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const handover = await repo.findOne({ where: { id: handoverId } });
|
||||
if (!handover) {
|
||||
throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||
}
|
||||
if (handover.signedAt) return handover;
|
||||
handover.signedAt = new Date();
|
||||
handover.signedByUserId = userId ?? null;
|
||||
handover.signerName = signerName?.trim() || null;
|
||||
return repo.save(handover);
|
||||
}
|
||||
|
||||
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
|
||||
async signForBooking(
|
||||
bookingId: string,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
/**
|
||||
* Export cargo is received into the warehouse to wait for its train, and only a
|
||||
* paid booking may be received — otherwise storage and a GRN would start against
|
||||
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
|
||||
* train and its receive is the unload.
|
||||
*
|
||||
* The guard touches only the DataSource, so the instance is built off the
|
||||
* prototype rather than stubbing all 20-odd collaborators.
|
||||
*/
|
||||
type Guard = (
|
||||
bookingId: string | null | undefined,
|
||||
direction: string | null,
|
||||
) => Promise<void>;
|
||||
|
||||
function makeGuard(paymentStatus: string | null) {
|
||||
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
|
||||
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
|
||||
service.dataSource = { query };
|
||||
const guard = (
|
||||
service as unknown as { assertExportBookingPaid: Guard }
|
||||
).assertExportBookingPaid.bind(service);
|
||||
return { guard, query };
|
||||
}
|
||||
|
||||
describe('receive() — export paid gate', () => {
|
||||
it('rejects an unpaid export booking', async () => {
|
||||
const { guard } = makeGuard('PENDING');
|
||||
|
||||
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows a paid export booking', async () => {
|
||||
const { guard } = makeGuard('PAID');
|
||||
|
||||
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('never blocks import, paid or not', async () => {
|
||||
const { guard, query } = makeGuard('PENDING');
|
||||
|
||||
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a receive with no booking attached', async () => {
|
||||
const { guard, query } = makeGuard('PENDING');
|
||||
|
||||
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -486,6 +486,21 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@Post('handovers/:handoverId/sign')
|
||||
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||
signHandover(
|
||||
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||
@Body() dto: ApproveDeliveryDto,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.inventoryService.signHandover(
|
||||
handoverId,
|
||||
user?.id ?? req.user?.id ?? req.user?.sub,
|
||||
dto.signerName,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/request-handover-signature')
|
||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
@@ -513,9 +528,16 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/handover-document')
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' })
|
||||
async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId);
|
||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||
async bookingHandoverDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Res() res: Response,
|
||||
@Query('handoverId') handoverId?: string,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(
|
||||
bookingId,
|
||||
handoverId || undefined,
|
||||
);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
@@ -534,6 +556,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/location')
|
||||
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingLocation(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver)
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import {
|
||||
Between,
|
||||
DataSource,
|
||||
EntityManager,
|
||||
FindManyOptions,
|
||||
FindOptionsWhere,
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThanOrEqual,
|
||||
MoreThanOrEqual,
|
||||
Raw,
|
||||
} from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { generateGrnNumber } from '../../common/grn.util';
|
||||
@@ -13,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.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 { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import {
|
||||
@@ -68,6 +82,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||
|
||||
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||
const HANDOVER_DOCUMENT_MARKER = '[Handover Document]';
|
||||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
@@ -396,6 +411,7 @@ export class WarehouseInventoryService {
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -417,12 +433,16 @@ export class WarehouseInventoryService {
|
||||
*
|
||||
* Covers both haulage paths because the gate does: a customer's own truck and
|
||||
* an EDR last-mile truck arrive at the same barrier and need the same paper.
|
||||
* "On site" means arrived and not yet departed.
|
||||
* Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see
|
||||
* what is coming as well as what is here — an assigned truck only stamps
|
||||
* `arrived_at` when it reaches the warehouse. A truck drops off the list once
|
||||
* it departs.
|
||||
*/
|
||||
async trucksOnSite(): Promise<
|
||||
Array<{
|
||||
source: 'CUSTOMER' | 'EDR';
|
||||
assignmentId: string;
|
||||
status: 'INBOUND' | 'ON_SITE';
|
||||
plateNumber: string | null;
|
||||
driverName: string | null;
|
||||
truckType: string | null;
|
||||
@@ -436,6 +456,7 @@ export class WarehouseInventoryService {
|
||||
return this.dataSource.query(
|
||||
`SELECT 'CUSTOMER' AS "source",
|
||||
a.id AS "assignmentId",
|
||||
CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||
a.plate_number AS "plateNumber",
|
||||
a.driver_name AS "driverName",
|
||||
a.truck_type AS "truckType",
|
||||
@@ -450,13 +471,13 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND a.arrived_at IS NOT NULL
|
||||
AND a.departed_at IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT 'EDR' AS "source",
|
||||
va.id AS "assignmentId",
|
||||
CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status",
|
||||
COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber",
|
||||
NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName",
|
||||
v.vehicle_type AS "truckType",
|
||||
@@ -474,10 +495,11 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE va.deleted_at IS NULL
|
||||
AND va.arrived_at IS NOT NULL
|
||||
AND va.departed_at IS NULL
|
||||
|
||||
ORDER BY "arrivedAt" ASC`,
|
||||
-- On-site trucks first, each group oldest-arrival first; inbound trucks
|
||||
-- (null arrival) sort to the end.
|
||||
ORDER BY "arrivedAt" ASC NULLS LAST`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -502,12 +524,20 @@ export class WarehouseInventoryService {
|
||||
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
|
||||
(SELECT count(*)::int FROM freight.customer_truck_assignments
|
||||
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite",
|
||||
-- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite()
|
||||
((SELECT count(*)::int FROM freight.customer_truck_assignments a
|
||||
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
|
||||
WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL)
|
||||
+
|
||||
(SELECT count(*)::int 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
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite",
|
||||
(SELECT count(*)::int FROM freight.warehouse_inventory
|
||||
WHERE deleted_at IS NULL
|
||||
AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED')
|
||||
AND status = ANY($1)
|
||||
AND created_at < now() - interval '7 days') AS "itemsAging"`,
|
||||
[this.IN_WAREHOUSE_STATUSES],
|
||||
);
|
||||
return {
|
||||
receivedToday: row?.receivedToday ?? 0,
|
||||
@@ -519,7 +549,7 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
/** In-warehouse statuses used by the dwell / aging metrics. */
|
||||
private readonly IN_WAREHOUSE_STATUSES = [
|
||||
private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [
|
||||
'RECEIVED',
|
||||
'UNLOADED',
|
||||
'STORED',
|
||||
@@ -947,7 +977,7 @@ export class WarehouseInventoryService {
|
||||
? LessThanOrEqual(new Date(filter.dateTo))
|
||||
: undefined;
|
||||
|
||||
const base = {
|
||||
const base: FindOptionsWhere<WarehouseInventory> = {
|
||||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||
@@ -961,6 +991,24 @@ export class WarehouseInventoryService {
|
||||
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
|
||||
};
|
||||
|
||||
// KPI drill-down filters — predicates mirror opsStats() exactly so the
|
||||
// dashboard card's count equals the length of the list it opens.
|
||||
if (filter.receivedToday) {
|
||||
base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`);
|
||||
}
|
||||
if (filter.pendingInspection) {
|
||||
base.status = 'RECEIVED';
|
||||
base.inspectionStatus = IsNull();
|
||||
}
|
||||
if (filter.agingOverDays) {
|
||||
if (!filter.status && !filter.pendingInspection) {
|
||||
base.status = In(this.IN_WAREHOUSE_STATUSES);
|
||||
}
|
||||
base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, {
|
||||
days: filter.agingOverDays,
|
||||
});
|
||||
}
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? [
|
||||
@@ -982,6 +1030,28 @@ export class WarehouseInventoryService {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse location rows for one booking, trimmed for the customer portal:
|
||||
* no staff guard on the route, so only location fields leave the API —
|
||||
* never notes, fees or inspection internals.
|
||||
*/
|
||||
async bookingLocation(bookingId: string) {
|
||||
const items = await this.inventoryRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return items.map((i) => ({
|
||||
id: i.id,
|
||||
bookingId: i.bookingId,
|
||||
status: i.status,
|
||||
arrivedAt: i.arrivedAt ?? null,
|
||||
warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null,
|
||||
yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null,
|
||||
zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.inventoryRepository.findById(id, {
|
||||
relations: { warehouse: { facility: true }, yard: true, zone: true },
|
||||
@@ -2550,6 +2620,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
@@ -2965,12 +3036,29 @@ export class WarehouseInventoryService {
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
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;
|
||||
// One reference per item — the first truck's arrival mints it, later trucks
|
||||
// (arrival or exit) reuse it so all exit papers share the release order.
|
||||
const reference =
|
||||
item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = {
|
||||
...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto),
|
||||
};
|
||||
// Weighbridge skipped on exit: the recorded net still comes from what the
|
||||
// truck is holding — the summed cargo weight of its selected containers.
|
||||
if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) {
|
||||
const selected = (exitInspectionDto.containerNumber ?? '')
|
||||
.split(/[,;\n]+/)
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean);
|
||||
if (selected.length) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons]));
|
||||
const heldTons = Number(
|
||||
selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3),
|
||||
);
|
||||
if (heldTons > 0) exitInspectionDto.netWeight = heldTons;
|
||||
}
|
||||
}
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||
@@ -2983,12 +3071,46 @@ export class WarehouseInventoryService {
|
||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||
: (exitInspectionDto.netWeight ?? null);
|
||||
|
||||
// The weight to record on the inventory when this truck leaves: prefer the
|
||||
// item's own container cargo weight (a truck may carry other items too);
|
||||
// fall back to the truck's recorded net. Fills an empty weight only.
|
||||
let recordedItemTons: number | null = null;
|
||||
if (isTruckLeaving && netTons != null) {
|
||||
recordedItemTons = netTons;
|
||||
if (item.containerId && item.bookingId) {
|
||||
const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query(
|
||||
`SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`,
|
||||
[item.containerId],
|
||||
);
|
||||
const ownNumber = cont?.containerNumber?.trim().toUpperCase();
|
||||
if (ownNumber) {
|
||||
const weights = await this.bookingContainerWeights(item.bookingId);
|
||||
const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber);
|
||||
if (own && own.weightTons > 0) recordedItemTons = own.weightTons;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||
notes: this.replaceExitInspectionNote(
|
||||
item.notes,
|
||||
exitInspectionNote,
|
||||
exitInspectionDto.truckPlateNumber,
|
||||
),
|
||||
});
|
||||
// Even an unweighed truck records the inventory weight it is holding —
|
||||
// without this the handover/exit papers print "0 t" for skipped weighings.
|
||||
if (recordedItemTons != null) {
|
||||
await manager.query(
|
||||
`UPDATE freight.warehouse_inventory
|
||||
SET weight = $2, updated_at = NOW()
|
||||
WHERE id = $1 AND COALESCE(weight, 0) = 0`,
|
||||
[id, recordedItemTons],
|
||||
);
|
||||
}
|
||||
if (!isTruckLeaving && item.bookingId) {
|
||||
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||||
// container as arrived (matched via the physical container number).
|
||||
@@ -3051,7 +3173,7 @@ export class WarehouseInventoryService {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
const [edrDeparted] = (await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
@@ -3065,7 +3187,8 @@ export class WarehouseInventoryService {
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
AND va.deleted_at IS NULL
|
||||
RETURNING va.id`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
@@ -3073,7 +3196,31 @@ export class WarehouseInventoryService {
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
)) as [Array<{ id: string }>, unknown];
|
||||
// EDR last-mile: the handover is generated the moment the truck exits
|
||||
// (with its exit paper) — one per truck — and the customer is asked to
|
||||
// sign it from the portal. Booking-level fallback when the plate matched
|
||||
// no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled.
|
||||
for (const row of edrDeparted) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
if (!edrDeparted.length) {
|
||||
const [lm]: Array<{ id: string }> = await manager.query(
|
||||
`SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (lm) {
|
||||
await this.handover.ensureForDepartedEdrTruck(
|
||||
item.bookingId,
|
||||
{ truckPlate: dto.truckPlateNumber.trim() },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -3123,11 +3270,43 @@ export class WarehouseInventoryService {
|
||||
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||
if (isTruckLeaving && item.bookingId) {
|
||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||
} else if (item.bookingId) {
|
||||
// Gate-in: same single-path hook for the arrival side (self-haul + EDR).
|
||||
void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */
|
||||
private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const body = `${truck} has arrived at the warehouse for booking ${ref}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck arrived at the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
@@ -3809,8 +3988,213 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Handover PDF resolved by booking (for the portal, which only has bookingId). */
|
||||
async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
/**
|
||||
* Customer signs ONE handover from the portal (EDR last-mile: one per truck).
|
||||
* When the last one is signed — and every EDR truck has left the warehouse —
|
||||
* the delivery completes automatically: inventory + cargo delivered, last-mile
|
||||
* leg DELIVERED (trucks freed), booking completed ("shipment delivered").
|
||||
*/
|
||||
async signHandover(
|
||||
handoverId: string,
|
||||
userId?: string,
|
||||
signerName?: string,
|
||||
): Promise<{
|
||||
handoverId: string;
|
||||
bookingId: string;
|
||||
signedAt: string | null;
|
||||
signerDisplayName: string;
|
||||
allSigned: boolean;
|
||||
}> {
|
||||
if (!userId) {
|
||||
throw new BadRequestException('Authentication is required to sign the handover');
|
||||
}
|
||||
const name = signerName?.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Please enter your full name to sign the handover');
|
||||
}
|
||||
|
||||
const [h]: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
mileType: string;
|
||||
edrAssignmentId: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate",
|
||||
mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[handoverId],
|
||||
);
|
||||
if (!h) throw new NotFoundException(`Handover ${handoverId} not found`);
|
||||
// Self-haul stays a single booking-level signature via approve-delivery,
|
||||
// which also enforces inspection-passed + truck-arrived. Per-truck signing
|
||||
// is an EDR last-mile flow only.
|
||||
if (h.mileType !== 'EDR_LAST_MILE') {
|
||||
throw new BadRequestException(
|
||||
'This handover is signed through Approve delivery, not per truck',
|
||||
);
|
||||
}
|
||||
|
||||
// Same gate as approve-delivery: storage/demurrage must be settled first.
|
||||
const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query(
|
||||
`SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`,
|
||||
[h.bookingId],
|
||||
);
|
||||
if (inv) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
const signed = await this.handover.sign(handoverId, userId, name);
|
||||
const allSigned = await this.handover.isFullySigned(h.bookingId);
|
||||
|
||||
if (inv) {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: inv.id,
|
||||
warehouseId: inv.warehouseId,
|
||||
description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`,
|
||||
performedBy: name,
|
||||
});
|
||||
}
|
||||
|
||||
// EDR last-mile delivers PER TRUCK: this signature confirms receipt of the
|
||||
// goods THIS truck carried, so only its containers become DELIVERED now.
|
||||
// (Self-haul keeps the single booking-level handover + manual Deliver.)
|
||||
if (h.mileType === 'EDR_LAST_MILE') {
|
||||
try {
|
||||
await this.deliverEdrTruckContainers(h, name);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (allSigned) {
|
||||
void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) =>
|
||||
this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
handoverId,
|
||||
bookingId: h.bookingId,
|
||||
signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null,
|
||||
signerDisplayName: name,
|
||||
allSigned,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile auto-completion: once every handover is signed and every EDR
|
||||
* truck has departed, deliver the remaining inventory, mark the last-mile leg
|
||||
* DELIVERED and complete the booking. Self-haul bookings keep their manual
|
||||
* Deliver flow (no last_mile record ⇒ no-op).
|
||||
*/
|
||||
private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise<void> {
|
||||
const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query(
|
||||
`SELECT id, status FROM freight.last_mile
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!lm) return;
|
||||
|
||||
const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (Number(pending?.notDeparted ?? 0) > 0) return;
|
||||
if (!(await this.handover.isFullySigned(bookingId))) return;
|
||||
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: 'Auto-delivered on customer handover signature',
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (lm.status !== 'DELIVERED') {
|
||||
try {
|
||||
await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Booking → COMPLETED ("shipment delivered" notification) — owned by the
|
||||
// bookings module; evented to avoid a warehouses→bookings service dependency.
|
||||
this.events.emit('import.handover.completed', { bookingId });
|
||||
}
|
||||
|
||||
/**
|
||||
* EDR last-mile per-truck delivery: the customer signed THIS truck's handover,
|
||||
* so only the container items that truck carried become DELIVERED. Bulk cargo
|
||||
* (no container rows) is delivered by completeEdrDeliveryIfReady once every
|
||||
* truck is signed off.
|
||||
*/
|
||||
private async deliverEdrTruckContainers(
|
||||
h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null },
|
||||
signerName: string,
|
||||
): Promise<void> {
|
||||
if (!h.edrAssignmentId && !h.truckPlate) return;
|
||||
const items: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT DISTINCT inv.id
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
JOIN freight.containers c
|
||||
ON c.container_number = COALESCE(vc.container_number, va.container_number)
|
||||
AND c.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND inv.status = 'READY_FOR_PICKUP'
|
||||
AND (va.id = $2::uuid
|
||||
OR ($2::uuid IS NULL
|
||||
AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`,
|
||||
[h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''],
|
||||
);
|
||||
for (const it of items) {
|
||||
try {
|
||||
await this.deliver(it.id, {
|
||||
receiverName: signerName,
|
||||
remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`,
|
||||
performedBy: signerName,
|
||||
} as DeliverInventoryDto);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handover PDF resolved by booking (for the portal, which only has bookingId).
|
||||
* With `handoverId` the document is rendered for that specific handover — the
|
||||
* per-truck EDR last-mile variant (truck plate + that truck's signature state).
|
||||
*/
|
||||
async handoverDocumentForBooking(
|
||||
bookingId: string,
|
||||
handoverId?: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
@@ -3821,7 +4205,29 @@ export class WarehouseInventoryService {
|
||||
if (!inv) {
|
||||
throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id);
|
||||
if (!handoverId) return this.handoverDocument(inv.id);
|
||||
|
||||
const [h]: Array<{
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: string | null;
|
||||
signerName: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT reference, truck_plate AS "truckPlate",
|
||||
signed_at AS "signedAt", signer_name AS "signerName"
|
||||
FROM freight.booking_handovers
|
||||
WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`,
|
||||
[handoverId, bookingId],
|
||||
);
|
||||
if (!h) {
|
||||
throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`);
|
||||
}
|
||||
return this.handoverDocument(inv.id, {
|
||||
reference: h.reference,
|
||||
truckPlate: h.truckPlate,
|
||||
signedAt: h.signedAt ? new Date(h.signedAt) : null,
|
||||
signerName: h.signerName,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve the primary warehouse-inventory item for a booking (most recent). */
|
||||
@@ -3849,12 +4255,22 @@ export class WarehouseInventoryService {
|
||||
return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId));
|
||||
}
|
||||
|
||||
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
async handoverDocument(
|
||||
id: string,
|
||||
perTruck?: {
|
||||
reference: string;
|
||||
truckPlate: string | null;
|
||||
signedAt: Date | null;
|
||||
signerName: string | null;
|
||||
},
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
-- An unweighed item still reports the cargo weight it holds: fall
|
||||
-- back to the item's container VGM when no weight was recorded.
|
||||
COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
@@ -3906,6 +4322,16 @@ export class WarehouseInventoryService {
|
||||
WHERE bc.booking_id = b.id
|
||||
AND bc.deleted_at IS NULL
|
||||
) booking_container ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT SUM(bcu.vgm_tons) AS tons
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc2
|
||||
ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL
|
||||
WHERE bc2.booking_id = b.id
|
||||
AND bcu.deleted_at IS NULL
|
||||
AND (container.container_number IS NULL
|
||||
OR bcu.container_number = container.container_number)
|
||||
) item_vgm ON true
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
@@ -3924,12 +4350,14 @@ export class WarehouseInventoryService {
|
||||
|
||||
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||
const reference =
|
||||
perTruck?.reference ||
|
||||
this.extractHandoverDocumentLine(row.notes, 'Handover Reference') ||
|
||||
`HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
|
||||
const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At');
|
||||
const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date();
|
||||
const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt;
|
||||
if (!generatedAtValue) {
|
||||
// Per-truck renders must not stamp their reference into the shared item notes.
|
||||
if (!generatedAtValue && !perTruck) {
|
||||
await this.inventoryRepository.update(id, {
|
||||
notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)),
|
||||
});
|
||||
@@ -3963,7 +4391,16 @@ export class WarehouseInventoryService {
|
||||
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||
trainSchedule: row.trainSchedule ?? null,
|
||||
lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null,
|
||||
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||
truckPlate: perTruck?.truckPlate ?? null,
|
||||
customerApproval: perTruck
|
||||
? perTruck.signedAt
|
||||
? {
|
||||
approvedAt: perTruck.signedAt.toISOString(),
|
||||
signerDisplayName: perTruck.signerName ?? '-',
|
||||
signatureImageUrl: null,
|
||||
}
|
||||
: null
|
||||
: this.extractCustomerDeliveryApproval(row.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -4002,14 +4439,61 @@ export class WarehouseInventoryService {
|
||||
if (!(await this.handover.isFullySigned(item.bookingId))) {
|
||||
throw new BadRequestException('Handover must be signed before delivery');
|
||||
}
|
||||
const [left]: Array<{ n: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
|
||||
const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left"
|
||||
FROM freight.customer_truck_assignments
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
if (Number(left?.n ?? 0) === 0) {
|
||||
const totalTrucks = Number(trucks?.total ?? 0);
|
||||
const leftTrucks = Number(trucks?.left ?? 0);
|
||||
if (leftTrucks === 0) {
|
||||
throw new BadRequestException('Deliver is available only after the customer truck has left');
|
||||
}
|
||||
// Multi-truck booking: every assigned truck must arrive and leave —
|
||||
// each is weighed out separately before the goods count as delivered.
|
||||
if (leftTrucks < totalTrucks) {
|
||||
throw new BadRequestException(
|
||||
`Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// EDR last-mile delivers per truck: a container item only needs the truck
|
||||
// CARRYING IT to have left; bulk (no container) waits for every truck.
|
||||
if (item.containerId) {
|
||||
const [own]: Array<{ pending: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL
|
||||
AND COALESCE(vc.container_number, va.container_number) = c.container_number`,
|
||||
[item.bookingId, item.containerId],
|
||||
);
|
||||
if (Number(own?.pending ?? 0) > 0) {
|
||||
throw new BadRequestException(
|
||||
'Deliver is available only after the EDR truck carrying this container has left',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL
|
||||
WHERE l.booking_id = $1 AND va.deleted_at IS NULL`,
|
||||
[item.bookingId],
|
||||
);
|
||||
const lmTotal = Number(lm?.total ?? 0);
|
||||
const lmLeft = Number(lm?.left ?? 0);
|
||||
if (lmTotal > 0 && lmLeft < lmTotal) {
|
||||
throw new BadRequestException(
|
||||
`Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4105,6 +4589,12 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
});
|
||||
|
||||
// "Approve delivery" nudge: on Deliver the customer is reminded to sign any
|
||||
// handover still unsigned (per truck for EDR last-mile). Fire-and-forget.
|
||||
if (item.bookingId) {
|
||||
void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -4832,10 +5322,11 @@ export class WarehouseInventoryService {
|
||||
releaseDate: Date | null;
|
||||
trainSchedule: string | null;
|
||||
lastMileDeliveryAddress: string | null;
|
||||
truckPlate?: string | null;
|
||||
customerApproval: {
|
||||
approvedAt: string;
|
||||
signerDisplayName: string;
|
||||
signatureImageUrl: string;
|
||||
signatureImageUrl: string | null;
|
||||
} | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -4881,6 +5372,7 @@ export class WarehouseInventoryService {
|
||||
['Release Order', data.releaseOrderReference],
|
||||
['Release Date', fmt(data.releaseDate)],
|
||||
['Last-mile Delivery Address', data.lastMileDeliveryAddress],
|
||||
...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []),
|
||||
];
|
||||
const approval = data.customerApproval;
|
||||
|
||||
@@ -5290,7 +5782,11 @@ export class WarehouseInventoryService {
|
||||
weighingSkipped ? 'Weighing: SKIPPED' : null,
|
||||
tareWeight == null ? null : `Tare Weight: ${tareWeight} t`,
|
||||
grossWeight == null ? null : `Gross Weight: ${grossWeight} t`,
|
||||
computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`,
|
||||
// Skipped weighing still records a net — the cargo weight of the
|
||||
// containers the truck is holding, resolved by the caller.
|
||||
(computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null
|
||||
? null
|
||||
: `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
@@ -5298,12 +5794,14 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||||
const inspection = this.extractExitInspectionNote(notes);
|
||||
const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber);
|
||||
if (!inspection) return dto;
|
||||
|
||||
return {
|
||||
...dto,
|
||||
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
// The submitted plate wins: a legacy block may store a comma-joined list
|
||||
// of plates, and the exit must be recorded against the ONE truck leaving.
|
||||
truckPlateNumber: dto.truckPlateNumber?.trim() || 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,
|
||||
@@ -5317,25 +5815,94 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
/**
|
||||
* Split notes into exit-inspection blocks (one per truck, in order) and
|
||||
* everything else. A block ends at the first line that isn't one of the
|
||||
* known inspection labels, so appended notes (delivery approval, handover
|
||||
* marker) are preserved as "other" content instead of being swallowed by
|
||||
* the block they happen to follow.
|
||||
*/
|
||||
private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
if (!trimmed) return exitInspectionNote;
|
||||
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = trimmed.lastIndexOf(marker);
|
||||
if (index < 0) {
|
||||
return `${trimmed}\n\n${exitInspectionNote}`;
|
||||
if (!trimmed) return { others: [], blocks: [] };
|
||||
const labelPattern =
|
||||
/^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i;
|
||||
const parts = trimmed.split(EXIT_INSPECTION_MARKER);
|
||||
const others: string[] = [];
|
||||
const blocks: string[] = [];
|
||||
if (parts[0]?.trim()) others.push(parts[0].trim());
|
||||
for (const part of parts.slice(1)) {
|
||||
const lines = part.split('\n');
|
||||
const kept: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length && !lines[i].trim()) i += 1;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || !labelPattern.test(line)) break;
|
||||
kept.push(line);
|
||||
}
|
||||
if (kept.length) blocks.push(kept.join('\n'));
|
||||
const tail = lines.slice(i).join('\n').trim();
|
||||
if (tail) others.push(tail);
|
||||
}
|
||||
return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n');
|
||||
return { others, blocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* A block belongs to a plate when its stored `Truck Plate` equals it, or is a
|
||||
* legacy comma-joined list ("P1, P2") containing it.
|
||||
*/
|
||||
private blockMatchesPlate(block: string, plateNumber?: string | null): boolean {
|
||||
const plate = plateNumber?.trim().toUpperCase();
|
||||
if (!plate) return false;
|
||||
const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase();
|
||||
if (!stored) return false;
|
||||
if (stored === plate) return true;
|
||||
return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace THIS truck's inspection block (matched by plate), keeping every
|
||||
* other truck's block untouched; append when the plate has no block yet.
|
||||
* A single legacy block (comma-joined plates or plate-less caller) is
|
||||
* replaced in place so old single-truck items keep their behaviour.
|
||||
*/
|
||||
private replaceExitInspectionNote(
|
||||
notes: string | null | undefined,
|
||||
exitInspectionNote: string | null,
|
||||
plateNumber?: string | null,
|
||||
): string | null {
|
||||
const { others, blocks } = this.splitExitInspectionSections(notes);
|
||||
if (exitInspectionNote) {
|
||||
const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim();
|
||||
const index = plateNumber
|
||||
? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber))
|
||||
: blocks.length - 1;
|
||||
if (index >= 0) blocks[index] = content;
|
||||
else blocks.push(content);
|
||||
}
|
||||
const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)];
|
||||
return sections.join('\n\n') || null;
|
||||
}
|
||||
|
||||
/** Latest truck's inspection block — legacy summary for documents. */
|
||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes.lastIndexOf(marker);
|
||||
if (index < 0) return null;
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
const { blocks } = this.splitExitInspectionSections(notes);
|
||||
return blocks.length ? blocks[blocks.length - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspection block for one truck. Falls back to a lone existing block so
|
||||
* legacy single-truck items (saved before per-plate blocks) keep working.
|
||||
*/
|
||||
private extractExitInspectionForPlate(
|
||||
notes: string | null | undefined,
|
||||
plateNumber?: string | null,
|
||||
): string | null {
|
||||
const { blocks } = this.splitExitInspectionSections(notes);
|
||||
const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber));
|
||||
if (match) return match;
|
||||
return blocks.length === 1 ? blocks[0] : null;
|
||||
}
|
||||
|
||||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||||
@@ -5648,6 +6215,31 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export cargo is received into the warehouse to wait for its train, and it is
|
||||
* received only once the booking is paid — receiving an unpaid export booking
|
||||
* would start storage and mint a GRN against cargo the customer has not settled.
|
||||
*
|
||||
* Export only: import cargo arrives OFF a train and its receive is the unload,
|
||||
* so gating that on payment would strand cargo already at the yard.
|
||||
*/
|
||||
private async assertExportBookingPaid(
|
||||
bookingId: string | null | undefined,
|
||||
direction: string | null,
|
||||
): Promise<void> {
|
||||
if (!bookingId || direction !== 'EXPORT') return;
|
||||
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
|
||||
`SELECT payment_status AS "paymentStatus"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: LocationNode,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { BookingStaff, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
@@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
// No class-level guard: the two reference GETs are open to any signed-in
|
||||
// staff (StaffReference), every other route carries its own permission.
|
||||
@Controller('warehouse-yards')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseYards.view)
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
@@ -19,12 +20,14 @@ export class WarehouseYardsController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||
findAll() {
|
||||
return this.yardsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@StaffReference()
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.findById(id);
|
||||
|
||||
@@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ── Intercity documents ─────────────────────────────────────────────────────
|
||||
// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations.
|
||||
// ONE_TIME contracts collect it at contract level after both signatures;
|
||||
// GENERAL contracts collect it per booking right after the booking is created.
|
||||
// Fields start empty and are configured in the backoffice file-settings editor.
|
||||
const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [
|
||||
{
|
||||
code: "intercity_documents",
|
||||
label: "Intercity documents",
|
||||
entity: "booking",
|
||||
fields: [],
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsSeeder {
|
||||
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||
@@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder {
|
||||
description:
|
||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||
})),
|
||||
...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
description:
|
||||
"Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.",
|
||||
})),
|
||||
];
|
||||
|
||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||
|
||||
@@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'priority-configs',
|
||||
'rates',
|
||||
'approval-rules',
|
||||
'yard-distances',
|
||||
] as const;
|
||||
|
||||
export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number];
|
||||
@@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string;
|
||||
'priority-configs': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' },
|
||||
rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' },
|
||||
'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' },
|
||||
'yard-distances': { view: 'b2000001-0001-4000-8000-000000000018', manage: 'b2000001-0001-4000-8000-000000000019' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5183 --clearScreen false",
|
||||
"dev": "vite --port 5283 --clearScreen false",
|
||||
"prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5183",
|
||||
|
||||
@@ -151,6 +151,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
permission: FREIGHT_PERMS.customers.view,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
@@ -162,6 +163,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
@@ -375,6 +377,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
children: [
|
||||
{
|
||||
label: "Import Overview",
|
||||
@@ -407,6 +410,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Exports",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
children: [
|
||||
{
|
||||
label: "Export Overview",
|
||||
@@ -449,6 +453,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Intercity",
|
||||
href: "/dashboard/intercity",
|
||||
icon: <TrainFront />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
children: [
|
||||
{
|
||||
label: "Intercity Cargo",
|
||||
@@ -465,6 +470,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.warehouseDashboard.view,
|
||||
},
|
||||
{
|
||||
// Yard-wide, not per-direction: the gate sees import and export
|
||||
@@ -472,21 +478,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Trucks on Site",
|
||||
href: "/dashboard/trucks-on-site",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouses.view,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.warehouseAllocationRules.view,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -523,10 +533,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -541,6 +553,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -596,10 +609,22 @@ const filterSidebarByPermission = (
|
||||
return permissionAllowed(item);
|
||||
};
|
||||
|
||||
// Recursive: a group's own permission gates the whole subtree, leaves are
|
||||
// checked individually, and a group with no surviving children disappears.
|
||||
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items.flatMap((item) => {
|
||||
if (item.children?.length) {
|
||||
if (item.permission && !permissionAllowed(item)) return [];
|
||||
const children = filterItems(item.children);
|
||||
return children.length ? [{ ...item, children }] : [];
|
||||
}
|
||||
return itemAllowed(item) ? [item] : [];
|
||||
});
|
||||
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter(itemAllowed),
|
||||
items: filterItems(section.items),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
@@ -708,7 +733,7 @@ const App = () => {
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route path="um/set-password" element={<SetPassword />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { captureApiError } from "@/lib/posthog";
|
||||
@@ -10,14 +11,16 @@ import {
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import type { AuthTokens } from "./types";
|
||||
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
|
||||
|
||||
declare module "axios" {
|
||||
export interface AxiosRequestConfig {
|
||||
/**
|
||||
* When true, the response interceptor does NOT raise the global error modal
|
||||
* for this request's failure. For calls the caller handles itself — e.g. a
|
||||
* probe that is expected to 404 before falling back (GL clearance detail
|
||||
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
|
||||
* When true, the response interceptor does NOT raise the global error
|
||||
* toast for this request's failure. For calls the caller handles itself —
|
||||
* e.g. a probe that is expected to 404 before falling back (GL clearance
|
||||
* detail tries /contracts/:id then /bookings/:id). The rejection still
|
||||
* propagates.
|
||||
*/
|
||||
suppressErrorModal?: boolean;
|
||||
}
|
||||
@@ -92,9 +95,8 @@ api.interceptors.response.use(
|
||||
async (error) => {
|
||||
const originalRequest = error.config as RetriableRequest | undefined;
|
||||
|
||||
// Report the failure to PostHog. Hooked here rather than inside
|
||||
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
||||
// mile / onboarding) — those failures still need reporting.
|
||||
// Report the failure to PostHog, including on suppressErrorModal paths —
|
||||
// those opt out of the user-facing toast, not of reporting.
|
||||
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||
if (!error.response || error.response.status !== 401) {
|
||||
captureApiError(error);
|
||||
@@ -108,16 +110,25 @@ api.interceptors.response.use(
|
||||
originalRequest.url?.includes("/auth/mfa-verify") ||
|
||||
originalRequest.url?.includes("/auth/refresh-token")
|
||||
) {
|
||||
// Surface the server's actual error message in the global error modal
|
||||
// (401s are handled by the session-refresh flow, so skip them). A request
|
||||
// may opt out via `suppressErrorModal` when it handles the failure itself.
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status !== 401 &&
|
||||
!originalRequest?.suppressErrorModal
|
||||
) {
|
||||
// const payload = extractApiErrorPayload(error);
|
||||
// if (payload) emitApiError(payload);
|
||||
// Surface the server's actual error message in a global toast — never
|
||||
// the error modal (401s are handled by the session-refresh flow, so skip
|
||||
// them). A request may opt out via `suppressErrorModal` when it handles
|
||||
// the failure itself.
|
||||
if (error.response && error.response.status !== 401) {
|
||||
const payload = extractApiErrorPayload(error);
|
||||
// Normalize the error's own `message` to the SERVER's actual message so
|
||||
// every downstream `toast.error(err.message)` handler shows the real
|
||||
// cause instead of "Request failed with status code NNN". Applies even
|
||||
// on suppressErrorModal paths — only the toast is opted out.
|
||||
if (payload?.messages.length) {
|
||||
const message = payload.messages.join("\n");
|
||||
(error as { message?: string }).message = message;
|
||||
// Keyed by message so a retried request replaces its toast instead
|
||||
// of stacking duplicates.
|
||||
if (!originalRequest?.suppressErrorModal) {
|
||||
toast.error(message, { id: message });
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AuthEmployeePosition {
|
||||
permissions?: AuthPermission[];
|
||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||
position?: { id?: string; key?: string; name?: LocaleText };
|
||||
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
|
||||
import { Check, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Button,
|
||||
Box,
|
||||
Modal,
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({
|
||||
const [rejectStepRow, setRejectStepRow] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
|
||||
// earlier APPROVED step to send the chain back to. First approver has no
|
||||
// choice — customer only.
|
||||
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
@@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({
|
||||
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
// The card also renders read-only trails (e.g. a REJECTED contract) — only
|
||||
// offer approve/reject while the backend accepts step actions.
|
||||
const actionable =
|
||||
contract.status === "PENDING_APPROVAL" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
// Approvers review a live preview of the document; there is no PDF to
|
||||
// generate first — the final approval is what produces it.
|
||||
@@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({
|
||||
const openReject = (step: Freight.IContractApprovalStep) => {
|
||||
setRejectStepRow(step);
|
||||
setRejectReason("");
|
||||
setRejectTarget("CUSTOMER");
|
||||
setRejectOpen(true);
|
||||
};
|
||||
|
||||
@@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({
|
||||
setRejectOpen(false);
|
||||
setRejectStepRow(null);
|
||||
setRejectReason("");
|
||||
setRejectTarget("CUSTOMER");
|
||||
};
|
||||
|
||||
const trimmedReason = rejectReason.trim();
|
||||
|
||||
// Earlier stages this rejection can be returned to — only stages that have
|
||||
// already approved. Empty for the first approver, whose only target is the
|
||||
// customer.
|
||||
const returnableSteps = rejectStepRow
|
||||
? steps.filter(
|
||||
(s) =>
|
||||
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
|
||||
)
|
||||
: [];
|
||||
|
||||
const sendBack = rejectTarget !== "CUSTOMER";
|
||||
const targetStep = sendBack
|
||||
? returnableSteps.find((s) => s.id === rejectTarget)
|
||||
: undefined;
|
||||
|
||||
const runReject = () => {
|
||||
if (!rejectStepRow || !trimmedReason) return;
|
||||
mutations.rejectStep.mutate(
|
||||
{ stepId: rejectStepRow.id, reason: trimmedReason },
|
||||
{
|
||||
stepId: rejectStepRow.id,
|
||||
reason: trimmedReason,
|
||||
returnToStepId: sendBack ? rejectTarget : undefined,
|
||||
},
|
||||
{ onSuccess: () => closeReject() },
|
||||
);
|
||||
};
|
||||
@@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -192,21 +223,56 @@ export function ContractApprovalStepsCard({
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Rejecting the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{rejectStepRow?.requiredRole}
|
||||
</Text>{" "}
|
||||
step rejects contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
outright. The customer must create a new contract — this cannot be
|
||||
undone.
|
||||
</Text>
|
||||
{returnableSteps.length > 0 && (
|
||||
<Select
|
||||
label="Send rejection to"
|
||||
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
|
||||
allowDeselect={false}
|
||||
value={rejectTarget}
|
||||
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
|
||||
data={[
|
||||
{ value: "CUSTOMER", label: "Customer — must resubmit" },
|
||||
...returnableSteps.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{sendBack ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
will go back to the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{targetStep?.requiredRole}
|
||||
</Text>{" "}
|
||||
step. That approver fixes the contract and approves again, and
|
||||
every later step re-approves in order. The customer is not
|
||||
notified.
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Rejecting the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{rejectStepRow?.requiredRole}
|
||||
</Text>{" "}
|
||||
step rejects contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>{" "}
|
||||
outright. The customer must resubmit — this cannot be undone.
|
||||
</Text>
|
||||
)}
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
description="Shared with the customer and the approval chain."
|
||||
description={
|
||||
sendBack
|
||||
? "Shared with the approval chain (not the customer)."
|
||||
: "Shared with the customer and the approval chain."
|
||||
}
|
||||
placeholder="Explain why this contract is rejected…"
|
||||
minRows={3}
|
||||
autosize
|
||||
@@ -219,14 +285,16 @@ export function ContractApprovalStepsCard({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
color={sendBack ? "orange" : "red"}
|
||||
radius="md"
|
||||
leftSection={<X size={16} />}
|
||||
loading={mutations.rejectStep.isPending}
|
||||
disabled={!trimmedReason}
|
||||
onClick={runReject}
|
||||
>
|
||||
Reject contract
|
||||
{sendBack
|
||||
? `Send back to ${targetStep?.requiredRole ?? "step"}`
|
||||
: "Reject contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -447,6 +447,17 @@ export default function GlCreateBookingForm() {
|
||||
if (!copyFromBooking || prefilled) return;
|
||||
const lines = copyFromBooking.bookingContainers ?? [];
|
||||
if (!lines.length) return;
|
||||
// The booking stores a numeric sizeFt (20) but the contract scope — and the
|
||||
// create payload the server validates — uses its own size strings ("20ft").
|
||||
// Seed with the scope's string so the rebook payload matches what a fresh
|
||||
// form entry would send.
|
||||
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
|
||||
if (sizeFt == null) return "";
|
||||
return (
|
||||
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
|
||||
`${sizeFt}ft`
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
@@ -466,7 +477,7 @@ export default function GlCreateBookingForm() {
|
||||
}))
|
||||
: Array.from({ length: qty }, emptyUnit);
|
||||
return {
|
||||
containerSize: String(c.containerType?.sizeFt ?? ""),
|
||||
containerSize: scopeSizeForFt(c.containerType?.sizeFt),
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
|
||||
reeferQuantity: String(units.filter((u) => u.isReefer).length),
|
||||
@@ -475,7 +486,7 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [copyFromBooking, prefilled]);
|
||||
}, [copyFromBooking, prefilled, containerSizes]);
|
||||
|
||||
// Seed one shipment line per contracted size exactly once — same seeding the
|
||||
// portal form does. Subsequent renders reuse the lines.
|
||||
|
||||
@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
|
||||
{ value: "CONTAINER_OPENED", label: "Container opened" },
|
||||
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
|
||||
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
|
||||
{ value: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
const LABEL: Record<Freight.IncidentType, string> = {
|
||||
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
|
||||
CONTAINER_OPENED: "Container opened",
|
||||
CONTAINER_DAMAGED: "Container damaged",
|
||||
FLUID_LEAKING: "Fluid leaking",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
|
||||
|
||||
@@ -249,6 +249,16 @@ const FleetFormDialog = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format check (e.g. plate numbers). Skipped for an empty optional field —
|
||||
// "required" above already owns the empty case. Upper-cased to match the
|
||||
// server, which stores plates upper-case.
|
||||
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
|
||||
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
|
||||
if (!field.pattern.regex.test(candidate)) {
|
||||
next[field.name] = field.pattern.message;
|
||||
}
|
||||
}
|
||||
});
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, Skeleton, Text } from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,6 +23,11 @@ export interface KpiItem {
|
||||
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
|
||||
*/
|
||||
delta?: number;
|
||||
/**
|
||||
* Optional route the cell links to — its detail view. When set the cell
|
||||
* becomes clickable (pointer, hover tint); when absent it stays static.
|
||||
*/
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface KpiStripProps {
|
||||
@@ -47,13 +53,22 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
{cells.map((item, index) => {
|
||||
const Icon = item.icon;
|
||||
const color = item.color ?? "edr-green";
|
||||
// A cell with an href becomes a link to its detail; without one it
|
||||
// stays a plain div. Same layout classes either way.
|
||||
const Cell: ElementType = item.href ? Link : "div";
|
||||
const linkProps = item.href
|
||||
? { to: item.href, "aria-label": `${item.label} — view detail` }
|
||||
: {};
|
||||
return (
|
||||
<div
|
||||
<Cell
|
||||
key={item.label}
|
||||
{...(linkProps as Record<string, unknown>)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 px-5 py-4",
|
||||
index > 0 &&
|
||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||
item.href &&
|
||||
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
@@ -102,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
{item.hint ? ` · ${item.hint}` : ""}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Cell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -46,10 +46,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
|
||||
// Admin-managed run list (dropdown settings); numbers already on a train
|
||||
// come back disabled so they cannot be picked twice.
|
||||
const importNumbers = useImportTrainNumberOptions();
|
||||
// Only serviceable locomotives standing in the selected yard can be coupled.
|
||||
// Only serviceable locomotives standing in the selected yard, and not already
|
||||
// coupled to another built train, can be picked. A new train owns none yet, so
|
||||
// no train to keep-exclude.
|
||||
const locomotivesQuery = useQuery(
|
||||
api.locomotives.listFiltered.queryOptions({
|
||||
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
|
||||
input: {
|
||||
filters: {
|
||||
status: "AVAILABLE",
|
||||
currentYardId: yardId,
|
||||
excludeCoupled: true,
|
||||
},
|
||||
},
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -26,9 +26,19 @@ export default function ChangeLocomotivesModal({
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
|
||||
const yardId = composition?.currentYard?.id ?? "";
|
||||
// A locomotive already coupled to ANOTHER built train is not a valid pick —
|
||||
// the API rejects it on save. Exclude those here (keeping this train's own
|
||||
// ones, which are re-listed below as "(coupled)").
|
||||
const availableQuery = useQuery(
|
||||
api.locomotives.listFiltered.queryOptions({
|
||||
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
|
||||
input: {
|
||||
filters: {
|
||||
status: "AVAILABLE",
|
||||
currentYardId: yardId,
|
||||
excludeCoupled: true,
|
||||
excludeTrainId: composition?.id,
|
||||
},
|
||||
},
|
||||
enabled: opened && Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,34 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
/** Statuses that block a deactivated train from reactivating (mirrors the API gate). */
|
||||
export const UNFIT_LOCOMOTIVE_STATUSES = new Set(["MAINTENANCE", "OUT_OF_SERVICE", "UNAVAILABLE"]);
|
||||
|
||||
/** Badge color per locomotive status (Mantine palette keys). */
|
||||
export const locomotiveStatusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case "AVAILABLE":
|
||||
case "IMPORT_READY":
|
||||
case "EXPORT_READY":
|
||||
return "edr-green";
|
||||
case "ASSIGNED":
|
||||
return "blue";
|
||||
case "MAINTENANCE":
|
||||
return "yellow";
|
||||
case "OUT_OF_SERVICE":
|
||||
case "UNAVAILABLE":
|
||||
return "red";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
export const locomotiveStatusLabel = (status: string): string =>
|
||||
String(status)
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
/** Badge color per trade direction (Mantine palette keys). */
|
||||
export const directionColor = (direction?: string | null): string =>
|
||||
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";
|
||||
|
||||
@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
|
||||
* confirmed manually when the train is physically at the booking's origin /
|
||||
* destination yard (the server validates against recorded checkpoints).
|
||||
*/
|
||||
/** Plain-language journey states for the accepted ride-along table. */
|
||||
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
|
||||
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
|
||||
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
|
||||
PAID: { label: "Paid — ready to load", color: "edr-green" },
|
||||
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
|
||||
COMPLETED: { label: "Delivered", color: "teal" },
|
||||
};
|
||||
|
||||
export function IntercityRideAlongPanel({
|
||||
scheduleId,
|
||||
direction,
|
||||
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
// Accepting/loading/unloading a ride-along changes the schedule's booking
|
||||
// list, the yard worklists AND this panel — refresh all three so the
|
||||
// workspace board and yard-work tables never show a stale picture.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||
});
|
||||
};
|
||||
|
||||
const accept = useMutation(
|
||||
api.trainScheduling.acceptIntercityBookings.mutationOptions({
|
||||
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
|
||||
<CorridorCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{row.status}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
|
||||
}
|
||||
>
|
||||
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
intercity={b.tradeDirection === "DOMESTIC"}
|
||||
leg={
|
||||
b.origin &&
|
||||
b.destination &&
|
||||
(b.originYardId !== schedule.originStation?.id ||
|
||||
b.destinationYardId !== schedule.destinationStation?.id)
|
||||
? `${b.origin} → ${b.destination}`
|
||||
: null
|
||||
}
|
||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||
right={
|
||||
canManage ? (
|
||||
@@ -840,6 +849,8 @@ function BookingCard({
|
||||
status,
|
||||
loadingStatus,
|
||||
waitingForWagon,
|
||||
intercity,
|
||||
leg,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
@@ -849,6 +860,10 @@ function BookingCard({
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
/** Paid, but no wagon of the required type was free — waiting for one. */
|
||||
waitingForWagon?: boolean;
|
||||
/** DOMESTIC ride-along riding only part of this train's corridor. */
|
||||
intercity?: boolean;
|
||||
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
|
||||
leg?: string | null;
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -874,6 +889,16 @@ function BookingCard({
|
||||
{reference}
|
||||
</Text>
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
{intercity ? (
|
||||
<Tooltip
|
||||
label="Intercity ride-along — rides only its own leg of this train's corridor"
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" radius="sm" variant="filled" color="indigo">
|
||||
Intercity
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{waitingForWagon ? (
|
||||
<Tooltip
|
||||
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
|
||||
@@ -907,6 +932,11 @@ function BookingCard({
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{leg ? (
|
||||
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{leg}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
|
||||
import { Box, Package } from "lucide-react";
|
||||
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
|
||||
|
||||
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
|
||||
type WagonSlot = (WagonPlanRow & {
|
||||
physicalWagonNumber?: string | null;
|
||||
tareWeightTons?: number | null;
|
||||
}) | {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
|
||||
@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
// Loading/unloading changes booking status on the schedule detail and the
|
||||
// intercity panel too — refresh all three so no surface shows a stale state.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
|
||||
});
|
||||
};
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
Building2,
|
||||
@@ -14,6 +15,13 @@ import { freightBrand } from "@/theme/freight-brand";
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
|
||||
|
||||
export interface WagonLoadMove {
|
||||
sourceWagonId: string;
|
||||
targetWagonId: string;
|
||||
}
|
||||
|
||||
type DragState = { sourceWagonId: string } | null;
|
||||
|
||||
interface InteractiveTrainConsistProps {
|
||||
wagons: Wagon[];
|
||||
locomotive: Locomotive | null | undefined;
|
||||
@@ -23,8 +31,16 @@ interface InteractiveTrainConsistProps {
|
||||
onSelectWagon: (wagon: Wagon) => void;
|
||||
/** Booking id to highlight across the train (e.g. selected in the side panel). */
|
||||
highlightBookingId?: string | null;
|
||||
/** Wagon loads become draggable: drop on an empty wagon to move, a loaded one to swap. */
|
||||
canRearrange?: boolean;
|
||||
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||
}
|
||||
|
||||
const wagonItems = (wagon: Wagon) =>
|
||||
(wagon.allocations ?? [])
|
||||
.flatMap((a) => a.containerItems ?? [])
|
||||
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
|
||||
@@ -151,16 +167,27 @@ function WagonCar({
|
||||
selected,
|
||||
highlighted,
|
||||
onSelect,
|
||||
drag,
|
||||
onDragChange,
|
||||
onMoveLoad,
|
||||
canRearrange,
|
||||
}: {
|
||||
wagon: Wagon;
|
||||
company: string | null;
|
||||
selected: boolean;
|
||||
highlighted: boolean;
|
||||
onSelect: () => void;
|
||||
drag: DragState;
|
||||
onDragChange: (drag: DragState) => void;
|
||||
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||
canRearrange: boolean;
|
||||
}) {
|
||||
const [dropHover, setDropHover] = useState(false);
|
||||
const allocation = wagon.allocations?.[0];
|
||||
const isEmpty = !allocation;
|
||||
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
|
||||
const isBulk = (wagon.allocations ?? []).some((a) =>
|
||||
(a.loadType ?? "").toUpperCase().includes("BULK"),
|
||||
);
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const tare = wagon.tareWeightTons ?? 0;
|
||||
const assigned =
|
||||
@@ -170,10 +197,20 @@ function WagonCar({
|
||||
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
|
||||
const accentVar = `var(--mantine-color-${accent}-6)`;
|
||||
|
||||
const containerNumbers = (allocation?.containerItems ?? []).map(
|
||||
(c) => c.containerNumber?.trim() || "—",
|
||||
);
|
||||
const blocks = containerNumbers.slice(0, 2);
|
||||
const items = wagonItems(wagon);
|
||||
const blocks = items.slice(0, 2);
|
||||
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
|
||||
|
||||
// The whole load drags as one unit (a 20ft pair never splits). Any OTHER
|
||||
// wagon is a drop target: empty → move (a consist-only wagon repins), loaded
|
||||
// → the two loads swap. The API validates wagon type + payload weight.
|
||||
const draggable = canRearrange && !isEmpty;
|
||||
const beingDragged = drag?.sourceWagonId === wagon.id;
|
||||
const dropEligible = Boolean(drag && !beingDragged);
|
||||
const endDrag = () => {
|
||||
onDragChange(null);
|
||||
setDropHover(false);
|
||||
};
|
||||
|
||||
const ringColor = selected
|
||||
? freightBrand.primary
|
||||
@@ -189,6 +226,21 @@ function WagonCar({
|
||||
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||
>
|
||||
<Box
|
||||
onDragOver={(e) => {
|
||||
if (dropEligible) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropHover(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDropHover(false)}
|
||||
onDrop={(e) => {
|
||||
if (dropEligible && drag) {
|
||||
e.preventDefault();
|
||||
onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id });
|
||||
}
|
||||
endDrag();
|
||||
}}
|
||||
style={{
|
||||
position: "relative",
|
||||
height: 70,
|
||||
@@ -205,10 +257,16 @@ function WagonCar({
|
||||
: isEmpty
|
||||
? "none"
|
||||
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||
outline: dropHover
|
||||
? "2px solid var(--mantine-color-cyan-6)"
|
||||
: dropEligible
|
||||
? "2px dashed var(--mantine-color-cyan-4)"
|
||||
: "none",
|
||||
outlineOffset: 2,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: "box-shadow 120ms ease",
|
||||
transition: "box-shadow 120ms ease, outline-color 120ms ease",
|
||||
}}
|
||||
>
|
||||
{/* top accent strip */}
|
||||
@@ -243,8 +301,27 @@ function WagonCar({
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* body */}
|
||||
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
||||
{/* body — the cargo area is the drag handle for the wagon's whole load */}
|
||||
<Box
|
||||
draggable={draggable}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
// Firefox needs data set for the drag to start.
|
||||
e.dataTransfer.setData("text/plain", wagon.id);
|
||||
onDragChange({ sourceWagonId: wagon.id });
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "3px 7px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: draggable ? "grab" : undefined,
|
||||
opacity: beingDragged ? 0.35 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||
Available
|
||||
@@ -274,28 +351,30 @@ function WagonCar({
|
||||
</Stack>
|
||||
) : (
|
||||
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
|
||||
(cn, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 26,
|
||||
borderRadius: 4,
|
||||
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
}}
|
||||
>
|
||||
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||
{cn}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
@@ -447,7 +526,10 @@ export const InteractiveTrainConsist = ({
|
||||
selectedWagonId,
|
||||
onSelectWagon,
|
||||
highlightBookingId,
|
||||
canRearrange = false,
|
||||
onMoveLoad,
|
||||
}: InteractiveTrainConsistProps) => {
|
||||
const [drag, setDrag] = useState<DragState>(null);
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -477,6 +559,10 @@ export const InteractiveTrainConsist = ({
|
||||
selected={selectedWagonId === wagon.id}
|
||||
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||
onSelect={() => onSelectWagon(wagon)}
|
||||
drag={drag}
|
||||
onDragChange={setDrag}
|
||||
onMoveLoad={onMoveLoad}
|
||||
canRearrange={canRearrange}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { MousePointerClick, TrainFront } from "lucide-react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { TrainStatsBar } from "./TrainStatsBar";
|
||||
import { WagonCard } from "./WagonCard";
|
||||
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
|
||||
import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist";
|
||||
import { RemoveBookingModal } from "./RemoveBookingModal";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
|
||||
}: TrainConsistViewProps) => {
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [removeModalOpen, setRemoveModalOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const unassignMutation = useMutation(
|
||||
api.trainScheduling.unassignBooking.mutationOptions(),
|
||||
@@ -54,9 +57,38 @@ export const TrainConsistView = ({
|
||||
const removeWagonMutation = useMutation(
|
||||
api.trainScheduling.removeWagonSlot.mutationOptions(),
|
||||
);
|
||||
const moveLoadMutation = useMutation(
|
||||
api.trainScheduling.moveWagonLoad.mutationOptions(),
|
||||
);
|
||||
|
||||
const trainSet = scheduleDetail.trainSet;
|
||||
const wagons = trainSet?.wagons ?? [];
|
||||
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
|
||||
|
||||
const handleMoveLoad = async (move: WagonLoadMove) => {
|
||||
if (moveLoadMutation.isPending) return;
|
||||
const targetLoaded =
|
||||
(wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0;
|
||||
try {
|
||||
await moveLoadMutation.mutateAsync({
|
||||
scheduleId,
|
||||
wagonId: move.sourceWagonId,
|
||||
targetWagonId: move.targetWagonId,
|
||||
});
|
||||
toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" });
|
||||
} catch (error) {
|
||||
const message = isAxiosError(error)
|
||||
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
|
||||
: null;
|
||||
toast({
|
||||
title: "Could not move the load",
|
||||
description: Array.isArray(message)
|
||||
? message.join(", ")
|
||||
: (message ?? "The move was rejected — check the wagon's type and payload."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Join company/customer name from schedule bookings by booking id.
|
||||
const companyByBooking = useMemo(() => {
|
||||
@@ -152,13 +184,21 @@ export const TrainConsistView = ({
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||
{canRearrange ? (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Hand size={12} color="var(--mantine-color-cyan-7)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Drag a wagon's cargo onto an empty wagon to move it — onto a loaded one to swap
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
<LegendDot color="gray" label="Empty" dashed />
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Box p="md">
|
||||
<Box p="md" style={{ opacity: moveLoadMutation.isPending ? 0.6 : 1 }}>
|
||||
<InteractiveTrainConsist
|
||||
wagons={wagons}
|
||||
locomotive={trainSet?.locomotive}
|
||||
@@ -166,6 +206,8 @@ export const TrainConsistView = ({
|
||||
selectedWagonId={selectedWagonId}
|
||||
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||
highlightBookingId={highlightBookingId}
|
||||
canRearrange={canRearrange && !moveLoadMutation.isPending}
|
||||
onMoveLoad={(move) => void handleMoveLoad(move)}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -178,7 +220,7 @@ export const TrainConsistView = ({
|
||||
Editing wagon #{selectedWagon.sequenceNo}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
Update container numbers or remove the booking
|
||||
Update container numbers, move containers to another wagon, or remove the booking
|
||||
</Text>
|
||||
</Group>
|
||||
<WagonCard
|
||||
@@ -192,6 +234,8 @@ export const TrainConsistView = ({
|
||||
scheduleStatus={scheduleDetail.status}
|
||||
onRemoveBooking={handleRemoveBooking}
|
||||
onRemoveWagon={handleRemoveWagon}
|
||||
wagons={wagons}
|
||||
onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined}
|
||||
/>
|
||||
</Box>
|
||||
) : wagons.length ? (
|
||||
@@ -209,7 +253,8 @@ export const TrainConsistView = ({
|
||||
<MousePointerClick size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed">
|
||||
Click a wagon in the train to edit container numbers or remove its booking.
|
||||
Click a wagon to edit its containers — or drag a container between wagons to
|
||||
rearrange the load.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Building2,
|
||||
Container as ContainerIcon,
|
||||
Fuel,
|
||||
@@ -10,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||
import { ContainerNumberInput } from "./ContainerNumberInput";
|
||||
import type { WagonLoadMove } from "./InteractiveTrainConsist";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||
@@ -21,8 +34,16 @@ interface WagonCardProps {
|
||||
scheduleStatus?: string;
|
||||
onRemoveBooking: (wagon: Wagon) => void;
|
||||
onRemoveWagon: (wagonId: string) => void;
|
||||
/** All wagons of the consist — targets for the move-load menu. */
|
||||
wagons?: Wagon[];
|
||||
onMoveLoad?: (move: WagonLoadMove) => void;
|
||||
}
|
||||
|
||||
const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0;
|
||||
|
||||
const isBulkWagon = (w: Wagon) =>
|
||||
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
|
||||
|
||||
export const WagonCard = ({
|
||||
wagon,
|
||||
company,
|
||||
@@ -30,6 +51,8 @@ export const WagonCard = ({
|
||||
scheduleStatus,
|
||||
onRemoveBooking,
|
||||
onRemoveWagon,
|
||||
wagons,
|
||||
onMoveLoad,
|
||||
}: WagonCardProps) => {
|
||||
const isDispatched = scheduleStatus === "DISPATCHED";
|
||||
const allocation = wagon.allocations?.[0];
|
||||
@@ -153,16 +176,62 @@ export const WagonCard = ({
|
||||
</Box>
|
||||
|
||||
{!isDispatched ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => onRemoveBooking(wagon)}
|
||||
fullWidth
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
<Group gap="xs" grow>
|
||||
{onMoveLoad ? (
|
||||
<Menu shadow="md" width={240} position="bottom" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
color="cyan"
|
||||
size="xs"
|
||||
leftSection={<ArrowLeftRight size={14} />}
|
||||
>
|
||||
Move load
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Move this wagon's load to</Menu.Label>
|
||||
{(wagons ?? [])
|
||||
.filter((w) => w.id !== wagon.id)
|
||||
.sort((a, b) => itemAllocCount(a) - itemAllocCount(b))
|
||||
.map((w) => {
|
||||
const loaded = itemAllocCount(w) > 0;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={w.id}
|
||||
onClick={() =>
|
||||
onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id })
|
||||
}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={600} truncate>
|
||||
#{w.sequenceNo} ·{" "}
|
||||
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={loaded ? (isBulkWagon(w) ? "orange" : "cyan") : "gray"}
|
||||
>
|
||||
{loaded ? "swap" : "empty"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="xs"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => onRemoveBooking(wagon)}
|
||||
>
|
||||
Remove booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -280,3 +280,135 @@ export function RouteCorridor({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
|
||||
export type SegmentStripBooking = {
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-segment wagon occupancy along the corridor: which legs are full and
|
||||
* which still run empty. Through cargo (unknown/off-route yards) occupies the
|
||||
* whole corridor; a ride-along counts only on its own leg — this is what makes
|
||||
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
|
||||
* glance instead of two disconnected booking lists.
|
||||
*/
|
||||
export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
|
||||
lastIdx;
|
||||
const rides = from <= edge && edge < (to > from ? to : lastIdx);
|
||||
if (!rides) continue;
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
|
||||
return (
|
||||
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
|
||||
{segments.map((seg, i) => {
|
||||
const used = seg.cargo + seg.intercity;
|
||||
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
|
||||
const full = cap != null && used >= cap;
|
||||
return (
|
||||
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
border: `2px solid ${freightBrand.primary}`,
|
||||
background: i === 0 ? "white" : freightBrand.primary,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.from.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
|
||||
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
|
||||
{used}
|
||||
{cap ? `/${cap}` : ""} wagons
|
||||
{full ? " · full" : ""}
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-2)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{cap ? (
|
||||
<>
|
||||
<Box
|
||||
style={{
|
||||
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
|
||||
background: freightBrand.primary,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
style={{
|
||||
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
|
||||
background: "var(--mantine-color-indigo-6)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Box style={{ width: pct ? `${pct}%` : 0 }} />
|
||||
)}
|
||||
</Box>
|
||||
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.cargo} cargo
|
||||
{seg.intercity > 0 ? (
|
||||
<Text span size="xs" fw={700} c="indigo.7">
|
||||
{" "}
|
||||
· {seg.intercity} intercity
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={9}
|
||||
h={9}
|
||||
style={{ borderRadius: 999, background: freightBrand.primary }}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
|
||||
{seg.to.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||
{/* Stays visible after the first exit — multi-truck bookings
|
||||
weigh each truck in and out until all have left. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Ready for pickup
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!r.hasAssignedTruck}
|
||||
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
>
|
||||
{r.hasAssignedTruck
|
||||
? r.releaseOrderReference
|
||||
? 'Truck leaving'
|
||||
? 'Truck arrival / leaving'
|
||||
: 'Truck arrival'
|
||||
: 'Truck arrival — assign a truck first'}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale, Truck } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
|
||||
|
||||
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
|
||||
item?.booking?.[key] == null ? '' : String(item.booking[key]);
|
||||
|
||||
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
|
||||
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
|
||||
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
|
||||
};
|
||||
|
||||
const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes?.lastIndexOf(marker) ?? -1;
|
||||
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
|
||||
return {
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
||||
};
|
||||
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
|
||||
interface InspectionBlock {
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
driverName: string;
|
||||
driverLicense: string;
|
||||
driverPhone: string;
|
||||
truckType: string;
|
||||
containerNumber: string;
|
||||
gateInTime: string;
|
||||
tareWeight: number | '';
|
||||
grossWeight: number | '';
|
||||
netWeight: number | '';
|
||||
gateOutTime: string;
|
||||
weighingSkipped: boolean;
|
||||
}
|
||||
|
||||
const parseInspectionSection = (note: string): InspectionBlock => ({
|
||||
truckPlateNumber: lineValue(note, 'Truck Plate'),
|
||||
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
|
||||
driverName: lineValue(note, 'Driver'),
|
||||
driverLicense: lineValue(note, 'Driver License'),
|
||||
driverPhone: lineValue(note, 'Driver Phone'),
|
||||
truckType: lineValue(note, 'Truck Type'),
|
||||
containerNumber: lineValue(note, 'Container Number'),
|
||||
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
|
||||
tareWeight: lineNumber(note, 'Tare Weight'),
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
|
||||
});
|
||||
|
||||
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
|
||||
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
|
||||
(notes ?? '')
|
||||
.split(EXIT_INSPECTION_MARKER)
|
||||
.slice(1)
|
||||
.map(parseInspectionSection)
|
||||
.filter((block) => block.truckPlateNumber);
|
||||
|
||||
/** Match by plate; a legacy block may hold a comma-joined plate list. */
|
||||
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
|
||||
const key = plate.trim().toUpperCase();
|
||||
if (!key) return undefined;
|
||||
return blocks.find((block) => {
|
||||
const stored = block.truckPlateNumber.toUpperCase();
|
||||
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
|
||||
});
|
||||
};
|
||||
|
||||
const blockArrived = (block: InspectionBlock | undefined) =>
|
||||
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
|
||||
|
||||
const blockLeft = (block: InspectionBlock | undefined) =>
|
||||
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
// Some openers (inventory workbench) supply bookingId without the booking
|
||||
// relation — fall back to it, or the truck/container-weight queries never run.
|
||||
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
// Plate whose saved block was last loaded into the form — stops the
|
||||
// per-plate loader effect from clobbering operator edits in a loop.
|
||||
const loadedPlateRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
const inspection = parseInspectionNote(item?.notes);
|
||||
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
|
||||
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
|
||||
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
|
||||
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
||||
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
||||
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
}
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
|
||||
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
|
||||
// truck query resolves, auto-fill the first assigned EDR truck — without
|
||||
// overwriting anything the operator typed or the locked exit-step values.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPrefill || isExitStep) return;
|
||||
const first = lastMileTrucks[0];
|
||||
if (!first) return;
|
||||
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
|
||||
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
|
||||
setDriverName((p) => p || first.driverName || '');
|
||||
setDriverLicense((p) => p || first.driverLicense || '');
|
||||
setDriverPhone((p) => p || first.driverPhone || '');
|
||||
setTruckType((p) => p || first.truckType || '');
|
||||
setContainerNumbers((prev) =>
|
||||
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
|
||||
);
|
||||
}, [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]);
|
||||
const savedBlocks = parseInspectionBlocks(item?.notes);
|
||||
|
||||
// Registered trucks for THIS booking, from both sources: EDR last-mile
|
||||
// (truckPrefill) and the customer portal (customer_truck_assignments).
|
||||
const assignedTruckOptions = [
|
||||
...(truckPrefill?.truckPlateNumber
|
||||
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||
? [
|
||||
{
|
||||
value: truckPrefill.truckPlateNumber,
|
||||
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
arrived: false,
|
||||
left: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: t.driverName,
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||
arrived: Boolean(t.arrivedAt),
|
||||
left: Boolean(t.departedAt),
|
||||
})),
|
||||
...lastMileTrucks
|
||||
.filter((t) => t.truckPlateNumber || t.vehicleId)
|
||||
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
driverName: t.driverName ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||
arrived: Boolean(t.arrivedAt),
|
||||
left: Boolean(t.departedAt),
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const truckSelectOptions = [
|
||||
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
|
||||
];
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
|
||||
// Per-truck progress: every truck is weighed in and out on its own; the saved
|
||||
// blocks also cover walk-in trucks that were never formally assigned.
|
||||
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
|
||||
for (const option of truckSelectOptions) {
|
||||
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
|
||||
}
|
||||
for (const block of savedBlocks) {
|
||||
const key = block.truckPlateNumber.trim().toUpperCase();
|
||||
const prior = truckProgress.get(key);
|
||||
truckProgress.set(key, {
|
||||
arrived: Boolean(prior?.arrived) || blockArrived(block),
|
||||
left: Boolean(prior?.left) || blockLeft(block),
|
||||
});
|
||||
}
|
||||
const totalTrucks = truckProgress.size;
|
||||
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
|
||||
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
|
||||
|
||||
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
|
||||
// (or a truck without a saved arrival) starts at the arrival step even when
|
||||
// other trucks of the booking are already mid-flow or gone.
|
||||
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
|
||||
const isExitStep = blockArrived(selectedBlock);
|
||||
const hasTruckLeft = blockLeft(selectedBlock);
|
||||
const isEntranceLocked = isExitStep;
|
||||
const selectedOption = truckSelectOptions.find(
|
||||
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
|
||||
);
|
||||
// Identity comes from the arrival record or the assignment — locked either
|
||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||
|
||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||
const applyTruckSelection = (plate: string) => {
|
||||
const block = blockForPlate(savedBlocks, plate);
|
||||
const option = truckSelectOptions.find(
|
||||
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
|
||||
);
|
||||
loadedPlateRef.current = plate.trim().toUpperCase();
|
||||
setTruckPlateNumber(plate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
? splitContainerNumbers(block.containerNumber)
|
||||
: (option?.containerNumbers ?? []);
|
||||
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
|
||||
setGateInTime(block?.gateInTime ?? '');
|
||||
setTareWeight(block?.tareWeight ?? '');
|
||||
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(block?.grossWeight ?? '');
|
||||
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||
setGateOutTime(block?.gateOutTime ?? '');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
loadedPlateRef.current = null;
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
// Initial truck: the caller's prefill, else the first truck still mid-flow
|
||||
// (arrived but not left) — the operator can switch trucks in the select.
|
||||
const prefillPlate =
|
||||
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
|
||||
? truckPrefill.truckPlateNumber
|
||||
: '';
|
||||
const blocks = parseInspectionBlocks(item?.notes);
|
||||
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
|
||||
// Legacy single-truck bookings stored the truck on the booking columns; a
|
||||
// comma-joined value means several trucks, so the operator picks instead.
|
||||
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
|
||||
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
|
||||
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
|
||||
const block = blockForPlate(blocks, initialPlate);
|
||||
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
|
||||
setTruckPlateNumber(initialPlate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(
|
||||
block?.driverName ||
|
||||
truckPrefill?.driverName ||
|
||||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
|
||||
);
|
||||
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(
|
||||
block?.truckType ||
|
||||
truckPrefill?.truckType ||
|
||||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
|
||||
);
|
||||
setContainerNumbers(
|
||||
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
|
||||
);
|
||||
setGateInTime(block?.gateInTime ?? '');
|
||||
setTareWeight(block?.tareWeight ?? '');
|
||||
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(block?.grossWeight ?? '');
|
||||
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
|
||||
setGateOutTime(block?.gateOutTime ?? '');
|
||||
}
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
// No truck chosen yet and exactly one is assigned — load it. With several
|
||||
// trucks the operator picks which one is at the gate.
|
||||
useEffect(() => {
|
||||
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
|
||||
if (truckSelectOptions.length !== 1) return;
|
||||
applyTruckSelection(truckSelectOptions[0].value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
|
||||
|
||||
// A typed plate that matches a saved arrival reloads that truck's record, so
|
||||
// the exit step opens with the weigh-in data instead of blank fields.
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const key = truckPlateNumber.trim().toUpperCase();
|
||||
if (!key || loadedPlateRef.current === key) return;
|
||||
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, truckPlateNumber]);
|
||||
|
||||
// Which containers ride this truck, and their combined cargo weight. When the
|
||||
// booking has container weights, that sum is the authoritative net; the
|
||||
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
);
|
||||
// Skip is only offered for container bookings; bulk always weighs.
|
||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
||||
// Even an unweighed truck records the cargo weight it is holding — the
|
||||
// selected containers' sum is the net that goes on the exit record.
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (hasTruckLeft) {
|
||||
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
|
||||
return;
|
||||
}
|
||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
weighingSkipped: skipWeighing || undefined,
|
||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
// Skipped weighing still records the net from what the truck holds.
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
|
||||
if (!isExitStep) {
|
||||
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
|
||||
toast({
|
||||
title: 'Truck arrival saved',
|
||||
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
|
||||
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
|
||||
});
|
||||
onClose();
|
||||
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const blob = response.data;
|
||||
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
|
||||
toast({
|
||||
title: 'Release exit paper issued',
|
||||
description: opened
|
||||
description: (opened
|
||||
? 'The PDF opened in a browser tab for printing or saving.'
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.',
|
||||
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
{totalTrucks > 1 && (
|
||||
<Alert icon={<Truck size={16} />} color="blue" variant="light">
|
||||
<Group gap="xs">
|
||||
<Text size="sm">
|
||||
{totalTrucks} trucks on this booking — each is weighed in and out separately.
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
|
||||
{arrivedTrucks}/{totalTrucks} arrived
|
||||
</Badge>
|
||||
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
|
||||
{leftTrucks}/{totalTrucks} left
|
||||
</Badge>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
{hasTruckLeft && (
|
||||
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||
<Text size="sm">
|
||||
Truck {truckPlateNumber} has already left — its exit record is locked. Pick another
|
||||
truck to continue the remaining arrivals and exits.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label="Release document reference"
|
||||
placeholder="e.g. REL-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={referenceLocked}
|
||||
/>
|
||||
{noTruckAssigned && (
|
||||
<Alert color="orange" variant="light" icon={<Info size={16} />}>
|
||||
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
)}
|
||||
{truckSelectOptions.length > 0 && (
|
||||
<Select
|
||||
label="Assigned first / last-mile truck"
|
||||
label="Truck at the gate"
|
||||
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
// Enabled at arrival so the operator picks which assigned truck came;
|
||||
// only locked on the exit (leaving) step once identity is captured.
|
||||
disabled={isEntranceLocked}
|
||||
data={truckSelectOptions}
|
||||
disabled={releaseMutation.isPending || downloading}
|
||||
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
|
||||
value,
|
||||
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
|
||||
}))}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
if (value) applyTruckSelection(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
disabled={hasTruckLeft}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
disabled={isEntranceLocked}
|
||||
/>
|
||||
{skipWeighing && (
|
||||
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Weighbridge skipped — the selected containers' cargo weight is recorded as the net.
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
|
||||
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
// After the first truck registers, the modal decides per truck whether it is
|
||||
// arriving or leaving — the item-level label covers both for multi-truck.
|
||||
const releaseActionLabel = (item: WarehouseInventoryItem) =>
|
||||
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
|
||||
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
|
||||
|
||||
const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
|
||||
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{/* After the first exit the primary action flips to Deliver, but a
|
||||
multi-truck booking still weighs its remaining trucks in and out. */}
|
||||
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'release')}
|
||||
>
|
||||
Truck Arrival / Leaving
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -23,18 +23,24 @@ export function WarehouseOpsKpiStrip() {
|
||||
delta:
|
||||
data != null ? data.receivedToday - data.receivedYesterday : undefined,
|
||||
hint: "vs yesterday",
|
||||
// Exactly the items behind the counter: received today.
|
||||
href: "/dashboard/warehouse-inventory?receivedToday=1",
|
||||
},
|
||||
{
|
||||
label: "Pending inspection",
|
||||
value: data?.pendingInspection ?? 0,
|
||||
icon: ClipboardCheck,
|
||||
color: "yellow",
|
||||
// RECEIVED items with no inspection recorded yet.
|
||||
href: "/dashboard/warehouse-inventory?pendingInspection=1",
|
||||
},
|
||||
{
|
||||
label: "Trucks on-site",
|
||||
value: data?.trucksOnSite ?? 0,
|
||||
icon: Truck,
|
||||
color: "blue",
|
||||
// Land on the On-site tab — the counter excludes inbound trucks.
|
||||
href: "/dashboard/trucks-on-site?scope=ON_SITE",
|
||||
},
|
||||
{
|
||||
label: "Items aging (>7d)",
|
||||
@@ -42,6 +48,7 @@ export function WarehouseOpsKpiStrip() {
|
||||
icon: AlertTriangle,
|
||||
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
|
||||
hint: "In warehouse over 7 days",
|
||||
href: "/dashboard/warehouse-inventory?agingOverDays=7",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user