mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
114
apps/edr-freight-api/py/prod-check-invoices-payments.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
-- ============================================================================
|
||||
-- Production DB drift check + fix for the batch/window flow.
|
||||
--
|
||||
-- WHY: BookingBatchService.reserve() calls billing.syncPayableDueDate, which
|
||||
-- queries freight.invoices.payments (a jsonb ledger added by migration
|
||||
-- 1828000000000-ExtendInvoicesForPartialPayment). If that column is MISSING on
|
||||
-- production (snapshot/restore drift — the migration can read as "applied" in
|
||||
-- freight.migrations while the DDL never took effect), every reserve() throws
|
||||
-- `column Invoice.payments does not exist`, the batch fill loop aborts mid-pass,
|
||||
-- and you see exactly:
|
||||
-- * only ONE booking gets a pay window (the loop dies after the first reserve
|
||||
-- whose invoice sync throws), and
|
||||
-- * reservations never expire cleanly (the settle path hits the same query).
|
||||
--
|
||||
-- Run STEP 1 first (read-only). If it shows the columns are MISSING, run STEP 2
|
||||
-- (idempotent, additive — safe to run even if partially applied).
|
||||
-- ============================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 1 — CHECK (read-only). Expect all 6 rows present; if any are missing,
|
||||
-- production has the drift and STEP 2 is required.
|
||||
-- ---------------------------------------------------------------------------
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'freight'
|
||||
AND table_name = 'invoices'
|
||||
AND column_name IN (
|
||||
'payments', 'subtotal_amount', 'tax_amount',
|
||||
'paid_amount', 'balance_amount', 'paid_at'
|
||||
)
|
||||
ORDER BY column_name;
|
||||
-- Also confirm the enum has the partial-payment statuses:
|
||||
SELECT unnest(enum_range(NULL::freight.invoices_status_enum))::text AS status;
|
||||
-- Expect ISSUED and PARTIALLY_PAID to be present.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 2 — FIX (idempotent). Only run if STEP 1 showed missing columns.
|
||||
-- Mirrors migration 1828000000000 up(); all ADD COLUMN IF NOT EXISTS, so
|
||||
-- re-running is safe. Wrapped so the enum additions (which cannot run inside a
|
||||
-- transaction block with immediate use) are applied first, then the columns.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Enum values (no-op if they already exist).
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';
|
||||
ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';
|
||||
|
||||
-- Money-tracking + payments ledger columns.
|
||||
ALTER TABLE freight.invoices
|
||||
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS paid_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- Backfill derived money fields for existing rows (only rows not already set).
|
||||
UPDATE freight.invoices
|
||||
SET subtotal_amount = total_amount,
|
||||
balance_amount = total_amount
|
||||
WHERE subtotal_amount = 0 AND balance_amount = 0;
|
||||
|
||||
UPDATE freight.invoices
|
||||
SET paid_amount = total_amount,
|
||||
balance_amount = 0,
|
||||
paid_at = COALESCE(paid_at, updated_at)
|
||||
WHERE status = 'PAID' AND paid_amount = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- STEP 3 — RE-CHECK. Re-run STEP 1; all 6 columns + both enum values should
|
||||
-- now be present. After this, deploy the freight_feature/usermanagement branch
|
||||
-- and the batch will reserve ALL fitting bookings + expire non-payers + top up.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- STEP 4 — BROADER DRIFT AUDIT (read-only). The same snapshot drift that hid
|
||||
-- invoices.payments can hide OTHER columns the batch flow selects. reserve()
|
||||
-- and settleReserved() load the FULL Booking entity, so ANY missing booking
|
||||
-- column throws mid-loop (e.g. we already hit
|
||||
-- `column Booking.consolidation_resume_status does not exist`). This lists every
|
||||
-- booking column the entity expects that is MISSING from production — expect
|
||||
-- ZERO rows. Any row = a drifted migration whose DDL must be re-applied.
|
||||
-- ============================================================================
|
||||
WITH expected(col) AS (
|
||||
SELECT unnest(ARRAY[
|
||||
'reference','customer_id','company_id','company_profile_id','is_government',
|
||||
'government_institution','train_id','status','contract_id','contract_route_id',
|
||||
'booking_type','contract_kind','created_by_role','created_by_user_id',
|
||||
'scheduled_date','estimated_shipment_date','expires_at','total_amount',
|
||||
'adjusted_total_amount','adjusted_by_staff_id','adjusted_at','adjustment_reason',
|
||||
'contract_validity_days','contract_valid_from','contract_valid_until',
|
||||
'payment_status','contract_type','service_type_id','customs_clearing_enabled',
|
||||
'customs_clearing_agent','equipment_return','origin_yard_id','destination_yard_id',
|
||||
'trade_direction','freight_type','cargo_type_id','cargo_free_text','shipping_line_id',
|
||||
'cargo_total_weight_vgm','is_hazardous','is_reefer','bulk_hazardous_quantity',
|
||||
'bulk_reefer_quantity','payment_currency','pnr_code','fully_executed_at',
|
||||
'pricing_breakdown','locked_at','priority_score','consolidation_partner_id',
|
||||
'consolidation_resume_status','wagons_required','scheduling_status',
|
||||
'hold_started_at','hold_expires_at','scheduled_at','train_schedule_id',
|
||||
'loaded_at','arrived_at','payment_deadline','selected_for_batch_at',
|
||||
'gl_station_yard_id','clearance_current_phase','duty_required',
|
||||
'vessel_departure_date','ro_amendment_requested_at','ro_hold_reason',
|
||||
'pre_clearance_finalized_at','gl_assigned_staff_id','gl_assigned_at'
|
||||
])
|
||||
)
|
||||
SELECT e.col AS missing_booking_column
|
||||
FROM expected e
|
||||
LEFT JOIN information_schema.columns c
|
||||
ON c.table_schema = 'freight' AND c.table_name = 'bookings' AND c.column_name = e.col
|
||||
WHERE c.column_name IS NULL
|
||||
ORDER BY e.col;
|
||||
-- If any rows come back, tell me which columns — I'll give you the exact
|
||||
-- migration(s) to re-apply (each is ADD COLUMN IF NOT EXISTS, idempotent).
|
||||
@@ -21,7 +21,9 @@ function makeManager(savedLines: unknown[]) {
|
||||
}
|
||||
|
||||
function makeEvents() {
|
||||
return { emit: jest.fn() };
|
||||
// BillingService emits via both emit() and emitAsync() (the post-commit async
|
||||
// listener path) — the mock must provide both.
|
||||
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
|
||||
}
|
||||
|
||||
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||
@@ -162,7 +164,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"booking.invoice.paid",
|
||||
expect.objectContaining({
|
||||
invoiceId: "inv-1",
|
||||
@@ -197,6 +199,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,6 +260,7 @@ describe("BillingService.recordPayment", () => {
|
||||
}),
|
||||
);
|
||||
expect(events.emit).not.toHaveBeenCalled();
|
||||
expect(events.emitAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
|
||||
@@ -268,7 +272,7 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(updated.balanceAmount).toBe(0);
|
||||
expect(updated.paidAt).toBeInstanceOf(Date);
|
||||
expect(mg.update).toHaveBeenCalled();
|
||||
expect(events.emit).toHaveBeenCalledWith(
|
||||
expect(events.emitAsync).toHaveBeenCalledWith(
|
||||
"warehouse.invoice.paid",
|
||||
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
|
||||
);
|
||||
@@ -296,3 +300,80 @@ describe("BillingService.recordPayment", () => {
|
||||
expect(mg.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression: `expirePayable` (batch settle path, called when a payment window
|
||||
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
|
||||
* The bug passed `dataSource.manager` (the non-transactional default) into the
|
||||
* transition, so runTransition skipped opening a transaction and the lock threw
|
||||
* `An open transaction is required for pessimistic lock` — aborting the whole
|
||||
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
|
||||
* write MUST run inside dataSource.transaction.
|
||||
*/
|
||||
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: "booking",
|
||||
sourceId: "booking-1",
|
||||
};
|
||||
|
||||
const build = (lookupResult: Record<string, unknown> | null) => {
|
||||
const defaultManager = {
|
||||
findOne: jest.fn().mockResolvedValue(lookupResult),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const txManager = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const transaction = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
|
||||
const events = makeEvents();
|
||||
const service = new BillingService(
|
||||
{ manager: defaultManager, transaction } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
events as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
|
||||
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
|
||||
const { service, transaction, txManager, defaultManager } = build(openInvoice);
|
||||
|
||||
await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(txManager.findOne).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
|
||||
);
|
||||
expect(txManager.update).toHaveBeenCalled();
|
||||
// The default manager only does the initial lock-free lookup, never a locked read.
|
||||
for (const call of defaultManager.findOne.mock.calls) {
|
||||
expect(call[1]).not.toHaveProperty("lock");
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op (no transaction) when there is no open invoice", async () => {
|
||||
const { service, transaction } = build(null);
|
||||
|
||||
const result = await service.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
"booking-1",
|
||||
"prepaid",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -825,6 +825,13 @@ export class BillingService {
|
||||
type?: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
// Lookup can use the default manager (no lock). But the pessimistic-lock write
|
||||
// inside `transition` NEEDS an open transaction: pass the caller's `manager`
|
||||
// through untouched (undefined when there is no caller txn) so `runTransition`
|
||||
// opens its own. Passing `this.dataSource.manager` here made `runTransition`
|
||||
// treat it as an already-open transaction and skip wrapping — the lock then
|
||||
// threw `An open transaction is required for pessimistic lock`, aborting the
|
||||
// whole settle pass (the "reservations settle/reserve one at a time" symptom).
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
@@ -842,7 +849,7 @@ export class BillingService {
|
||||
Freight.InvoiceStatus.Expired,
|
||||
"expired",
|
||||
{},
|
||||
mg,
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,21 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Body() dto: AddCustomerTruckDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.updateTruck(id, assignmentId, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||
async removeCustomerTruck(
|
||||
|
||||
@@ -57,6 +57,15 @@ export class CustomerTruckService {
|
||||
|
||||
if (requested.length) {
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
// Never assign more trucks than the booking has containers.
|
||||
const existingTrucks = await this.dataSource
|
||||
.getRepository(CustomerTruckAssignment)
|
||||
.count({ where: { bookingId } });
|
||||
if (existingTrucks + 1 > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${existingTrucks} truck(s) already assigned.`,
|
||||
);
|
||||
}
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
@@ -140,6 +149,76 @@ export class CustomerTruckService {
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a truck assignment — plate/driver/type and the containers it carries.
|
||||
* Allowed only until the truck has arrived (same guard as removal). Container
|
||||
* rules mirror {@link addTruck}: 1–2 of the booking's containers, none already
|
||||
* on another truck, and a 40ft container fills the truck (max 1).
|
||||
*/
|
||||
async updateTruck(
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
dto: AddCustomerTruckDto,
|
||||
): Promise<CustomerTruckAssignment[]> {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||
if (!assignment || assignment.bookingId !== bookingId) {
|
||||
throw new NotFoundException('Truck assignment not found for this booking');
|
||||
}
|
||||
if (assignment.arrivedAt) {
|
||||
throw new ConflictException('Cannot edit a truck that has already arrived');
|
||||
}
|
||||
|
||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||
if (requested.length < 1) {
|
||||
throw new BadRequestException('Select at least one container for this truck');
|
||||
}
|
||||
if (requested.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
for (const n of requested) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
}
|
||||
// Exclude THIS truck's own containers so re-saving the same set is allowed.
|
||||
const assignedElsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||
for (const n of requested) {
|
||||
if (assignedElsewhere.includes(n)) {
|
||||
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||
}
|
||||
}
|
||||
const sizes = await this.containerSizes(bookingId, requested);
|
||||
if (sizes.some((s) => s.includes('40')) && requested.length > 1) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: dto.driverName.trim(),
|
||||
truckType: dto.truckType.trim(),
|
||||
});
|
||||
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||
await manager.getRepository(CustomerTruckContainer).save(
|
||||
requested.map((containerNumber) =>
|
||||
manager.getRepository(CustomerTruckContainer).create({
|
||||
assignmentId,
|
||||
bookingId,
|
||||
containerNumber,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return this.listTrucks(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/**
|
||||
* Best-effort SMS + email fan-out to a company's contacts. Looks up the
|
||||
* company's phone/email and sends the message over both channels, swallowing
|
||||
* per-channel failures so a missing provider never breaks the caller's flow.
|
||||
*/
|
||||
export async function sendCompanyChannels(
|
||||
dataSource: DataSource,
|
||||
notifications: NotificationsService,
|
||||
companyId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const [contact]: Array<{ phone: string | null; email: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT COALESCE(phone, etrade_phone) AS phone, email
|
||||
FROM freight.companies
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[companyId],
|
||||
);
|
||||
if (contact?.phone) {
|
||||
try {
|
||||
await notifications.directSend('sms', contact.phone, message);
|
||||
} catch {
|
||||
/* best-effort: SMS provider unavailable */
|
||||
}
|
||||
}
|
||||
if (contact?.email) {
|
||||
try {
|
||||
await notifications.directSend('email', contact.email, message);
|
||||
} catch {
|
||||
/* best-effort: email provider unavailable */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,8 @@ export interface BatchBoardSchedule {
|
||||
/** Weight committed on the train (allocated + selected-for-batch). */
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
@@ -290,6 +292,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
this.logger.log(
|
||||
`[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||
);
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
@@ -863,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -902,6 +907,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
lengthMeters: number;
|
||||
}>,
|
||||
loco: Locomotive | null,
|
||||
maxWagons: number | null,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
@@ -918,6 +924,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
|
||||
100,
|
||||
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
|
||||
maxWagons: maxWagons ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -956,7 +963,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||
}
|
||||
: null,
|
||||
capacity: this.computeBoardCapacity(items, loco),
|
||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||
counts: {
|
||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||
@@ -1033,6 +1040,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let reservedThisPass = 0;
|
||||
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
@@ -1087,18 +1095,33 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
if (isGov) {
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
// Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup)
|
||||
// can't abort the whole top-up pass and leave the rest to trickle in one
|
||||
// per tick. Log + skip the failing unit, keep going.
|
||||
try {
|
||||
if (isGov) {
|
||||
await this.allocate(scheduleId, booking, "gov");
|
||||
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` +
|
||||
`— skipping this unit, continuing: ${(err as Error).message}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
@@ -1190,6 +1213,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
let reservedThisPass = 0;
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
@@ -1256,17 +1280,35 @@ export class BookingBatchService implements OnModuleInit {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGov) {
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
if (partner) await this.allocate(target.id, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
// A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort
|
||||
// the whole pass — otherwise only the bookings before the failure get a pay
|
||||
// window and the rest trickle in one-per-tick on later retries (the
|
||||
// "selected one at a time / staggered" symptom). Isolate each unit: log +
|
||||
// skip a failing one, keep reserving the others. The skipped unit stays in
|
||||
// the pool and is retried next cycle.
|
||||
try {
|
||||
if (isGov) {
|
||||
await this.allocate(target.id, booking, "gov");
|
||||
if (partner) await this.allocate(target.id, partner, "gov");
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` +
|
||||
`— skipping this unit, continuing the batch: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
|
||||
);
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
@@ -1402,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||
const done = new Set<string>();
|
||||
let anySettled = false;
|
||||
this.logger.debug(
|
||||
`[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`,
|
||||
);
|
||||
|
||||
const isPaid = (b: Booking) =>
|
||||
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||
@@ -1448,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const anySettled = await this.settleReserved(scheduleId, false);
|
||||
if (anySettled) await this.fillSchedule(scheduleId);
|
||||
// A settle that allocated/expired anything frees or fills capacity → re-run the
|
||||
// fill so the next waiting-list bookings get a fresh pay window (top-up).
|
||||
if (anySettled) {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
await this.fillSchedule(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settle (1h after a batch) -------------------------------------------
|
||||
@@ -1637,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"PREPAID",
|
||||
);
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
this.logger.log(
|
||||
`[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` +
|
||||
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` +
|
||||
`pay by ${deadline.toISOString()}`,
|
||||
);
|
||||
// Customer tracking: a wagon slot is reserved and the freight pay window is
|
||||
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
|
||||
void this.completeTrackingMilestones(booking.id, [
|
||||
@@ -1671,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
this.logger.log(
|
||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||
);
|
||||
this.notifier.secured(booking, reason);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
@@ -1738,6 +1798,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// source-agnostic.
|
||||
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
|
||||
`wagons back to the pool for top-up`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1793,6 +1857,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
if (unaccepted.length > 0) {
|
||||
this.logger.log(
|
||||
`[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` +
|
||||
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||
);
|
||||
}
|
||||
for (const booking of unaccepted) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
@@ -1807,8 +1877,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.catch(() => undefined);
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` +
|
||||
`(${group.originYardId}->${group.destinationYardId} ${group.day})`,
|
||||
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,24 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
|
||||
@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
|
||||
now,
|
||||
);
|
||||
} catch (err) {
|
||||
// This is THE line to watch when a window freezes mid-phase: the tick
|
||||
// catches a throw here per-schedule and moves on, so a schedule whose
|
||||
// transition keeps throwing stays stuck in its phase forever. Log the
|
||||
// phase + stack so the failing step is obvious.
|
||||
this.logger.error(
|
||||
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
|
||||
`[WINDOW] transition FAILED for schedule ${schedule.id} ` +
|
||||
`(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
|
||||
`${(err as Error).message}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
|
||||
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
|
||||
this.logger.log(
|
||||
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
|
||||
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
|
||||
`(cycle ${schedule.bookingCycleNo})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
|
||||
`review until ${docReviewEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
// is handled inside the fill (all fit → all reserved → all notified).
|
||||
await this.bookingBatchService.processRouteDay(routeDay);
|
||||
this.logger.log(
|
||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
|
||||
`until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -287,6 +299,10 @@ export class BookingWindowService implements OnModuleInit {
|
||||
schedule.paymentPhaseEndsAt != null &&
|
||||
now >= schedule.paymentPhaseEndsAt
|
||||
) {
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
|
||||
`(allocate paid / expire unpaid) then concluding the cycle`,
|
||||
);
|
||||
await this.bookingBatchService.settleDueReservations(schedule.id);
|
||||
await this.concludeCycle(schedule, cfg, now);
|
||||
return true;
|
||||
@@ -306,6 +322,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
await this.tryAutoFinalize(schedule.id);
|
||||
this.logger.log(
|
||||
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -324,7 +343,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
if (nextOpensAt == null) {
|
||||
await this.setPhase(schedule, { windowPhase: 'DONE' });
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
|
||||
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
|
||||
`departure — window DONE`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -347,7 +367,8 @@ export class BookingWindowService implements OnModuleInit {
|
||||
});
|
||||
const sameDay = eatDay(nextOpensAt) === eatDay(now);
|
||||
this.logger.log(
|
||||
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
`[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
|
||||
`REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ describe('TrainSchedulingService', () => {
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -72,6 +72,7 @@ import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
computeFleetAvailability,
|
||||
@@ -281,10 +282,38 @@ export class TrainSchedulingService {
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
*/
|
||||
private async notifyScheduleBookings(
|
||||
schedule: TrainSchedule,
|
||||
event: 'dispatched' | 'arrived',
|
||||
): Promise<void> {
|
||||
try {
|
||||
const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
|
||||
const destination =
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
|
||||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(ids) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete customer-tracking clearance milestones for every booking on a
|
||||
* schedule when a physical lifecycle event fires (dispatch, arrive, load,
|
||||
@@ -1545,6 +1574,7 @@ export class TrainSchedulingService {
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2550,6 +2580,7 @@ export class TrainSchedulingService {
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
void this.notifyScheduleBookings(schedule, 'arrived');
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, Matches, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@@ -58,4 +58,9 @@ export class CreateWarehouseDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES, default: 'ACTIVE' })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Optional explicit storage location. When warehouse/yard/zone are all provided,
|
||||
* the item is stored there directly; otherwise store() falls back to the
|
||||
* allocation-rule / capacity-balanced auto pick.
|
||||
*/
|
||||
export class StoreInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -34,7 +34,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, W
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
UNLOADED_AT_DJIBOUTI_PORT: [],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
// Reserve is retired from the operator flow — a stored export item advances
|
||||
// straight to loading prep. RESERVED kept for any in-flight/legacy items.
|
||||
STORED: ['RESERVED', 'READY_FOR_LOADING'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
|
||||
import { BookingHandover } 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';
|
||||
|
||||
/**
|
||||
* Import handover records. A booking has one handover per truck (single truck ⇒
|
||||
@@ -13,7 +17,35 @@ import { BookingHandover } from './entities/booking-handover.entity';
|
||||
export class HandoverService {
|
||||
private readonly logger = new Logger(HandoverService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Tell the customer a handover is ready and needs their signature. */
|
||||
private async notifySignNeeded(bookingId: string, reference: string): 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.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
title: 'Handover — signature needed',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, reference },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify handover sign for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
list(bookingId: string): Promise<BookingHandover[]> {
|
||||
return this.dataSource.getRepository(BookingHandover).find({
|
||||
@@ -22,6 +54,32 @@ export class HandoverService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the customer to sign the booking's handover. Ensures a handover exists
|
||||
* (creates a booking-level self-haul one if none yet), then fires the
|
||||
* sign-needed notification (in-app + SMS + email). Idempotent to re-send.
|
||||
*/
|
||||
async requestSignature(
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> {
|
||||
const repo = this.dataSource.getRepository(BookingHandover);
|
||||
const existing = await repo.find({ where: { bookingId }, order: { generatedAt: 'ASC' } });
|
||||
|
||||
if (existing.length === 0) {
|
||||
// No handover yet (truck not arrived): create a booking-level one so the
|
||||
// customer has something to sign. ensureForArrivedTruck notifies on create.
|
||||
const created = await this.ensureForArrivedTruck(bookingId, {});
|
||||
return { notified: true, reference: created.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
const unsigned = existing.find((h) => !h.signedAt);
|
||||
if (!unsigned) {
|
||||
return { notified: false, reference: existing[0].reference, alreadySigned: true };
|
||||
}
|
||||
await this.notifySignNeeded(bookingId, unsigned.reference);
|
||||
return { notified: true, reference: unsigned.reference, alreadySigned: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-haul: ensure a handover exists for a customer truck that just arrived.
|
||||
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
|
||||
@@ -54,6 +112,7 @@ export class HandoverService {
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
|
||||
void this.notifySignNeeded(bookingId, reference);
|
||||
return saved;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { CreateInspectionReportDto } from './dto/create-inspection-report.dto';
|
||||
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
|
||||
@@ -13,11 +18,15 @@ const INSPECTION_RESOURCE = 'warehouse-inspection-report';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInspectionService {
|
||||
private readonly logger = new Logger(WarehouseInspectionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inspectionRepository: WarehouseInspectionRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/** Create or update the inspection report for an inventory item and sync its inspectionStatus. */
|
||||
@@ -83,8 +92,10 @@ export class WarehouseInspectionService {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "companyId",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
b.last_mile_delivery_address AS "lastMileDeliveryAddress",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
COALESCE(st.includes_last_mile, false) AS "serviceIncludesLastMile"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
@@ -105,6 +116,36 @@ export class WarehouseInspectionService {
|
||||
|
||||
if (row.bookingReference && hasLastMile) {
|
||||
await this.lastMileService.acceptBooking(row.bookingReference);
|
||||
} else if (!hasLastMile && !row.customerTruckAssignedAt) {
|
||||
// Self-haul import: goods are pickup-ready but no collection truck is
|
||||
// assigned yet — nudge the customer to assign one from the portal.
|
||||
void this.notifyTruckAssignmentNeeded(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** Portal nudge: import goods are ready for pickup but no customer truck is assigned. */
|
||||
private async notifyTruckAssignmentNeeded(row: {
|
||||
bookingId?: string | null;
|
||||
bookingReference?: string | null;
|
||||
companyId?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!row.companyId || !row.bookingId) return;
|
||||
const body = `Booking ${row.bookingReference ?? row.bookingId} has passed inspection and is ready for pickup. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: row.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${row.bookingId}`,
|
||||
data: { bookingId: row.bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, row.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-assignment notify failed for ${row.bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { StoreInventoryDto } from './dto/store-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
@@ -267,9 +268,9 @@ export class WarehouseInventoryController {
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) {
|
||||
return this.inventoryService.store(id, dto.performedBy, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@@ -354,12 +355,24 @@ export class WarehouseInventoryController {
|
||||
return this.handoverService.list(bookingId);
|
||||
}
|
||||
|
||||
@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) {
|
||||
return this.handoverService.requestSignature(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-items')
|
||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.containerItems(bookingId);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingId/container-weights')
|
||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
@@ -39,6 +40,8 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
|
||||
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
|
||||
import { HandoverService } from './handover.service';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
/** Wagon states that may receive a load (besides being part of an existing schedule). */
|
||||
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
|
||||
@@ -356,12 +359,14 @@ export interface ImportUnloadedRow {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -383,8 +388,41 @@ export class WarehouseInventoryService {
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly signatures: SignaturesService,
|
||||
private readonly handover: HandoverService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* When a self-haul booking (no EDR first/last mile) is received to the warehouse
|
||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||
*/
|
||||
private async notifyTruckAssignmentNeeded(booking: {
|
||||
companyId?: string | null;
|
||||
reference?: string | null;
|
||||
hasFirstMile?: boolean;
|
||||
hasLastMile?: boolean;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}, bookingId: string): Promise<void> {
|
||||
if (!booking.companyId) return;
|
||||
if (booking.hasFirstMile || booking.hasLastMile) return; // EDR mile — no customer truck
|
||||
if (booking.customerTruckAssignedAt) return; // already assigned
|
||||
const body = `Booking ${booking.reference ?? bookingId} has been received at the warehouse. Please assign your collection truck(s) from the portal to proceed.`;
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Assign a truck for pickup',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, action: 'ASSIGN_TRUCK' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Truck-assignment notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 6 — final terminal release / gate clearance.
|
||||
* Blocked while an unpaid demurrage/storage invoice exists. Does NOT touch
|
||||
@@ -866,7 +904,10 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_driver_name AS "customerTruckDriverName",
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
b.company_id AS "companyId",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "hasLastMile"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -1002,6 +1043,7 @@ export class WarehouseInventoryService {
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
void this.notifyTruckAssignmentNeeded(booking, bookingId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1301,12 +1343,18 @@ export class WarehouseInventoryService {
|
||||
b.customer_truck_type AS "customerTruckType",
|
||||
b.customer_truck_container_number AS "customerTruckContainerNumber",
|
||||
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
|
||||
(b.customer_truck_assigned_at IS NOT NULL
|
||||
OR EXISTS (SELECT 1 FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id
|
||||
AND lm.vehicle_id IS NOT NULL
|
||||
AND lm.deleted_at IS NULL)) AS "hasAssignedTruck",
|
||||
inv.status AS "currentStatus",
|
||||
inv.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
substring(inv.notes FROM 'Handover Reference: ([^\\n\\r]+)') AS "handoverDocumentReference",
|
||||
substring(inv.notes FROM 'Generated At: ([^\\n\\r]+)') AS "handoverDocumentDate",
|
||||
inv.delivered_at AS "deliveredAt",
|
||||
inv.notes AS "notes",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
@@ -2104,13 +2152,30 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
async store(
|
||||
id: string,
|
||||
performedBy?: string,
|
||||
chosen?: { warehouseId?: string; yardId?: string; zoneId?: string },
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'STORED');
|
||||
|
||||
// Explicit location wins when the operator picked warehouse + yard + zone;
|
||||
// otherwise fall back to the allocation-rule / capacity-balanced auto pick.
|
||||
const manualLocation =
|
||||
chosen?.warehouseId && chosen?.yardId && chosen?.zoneId
|
||||
? {
|
||||
warehouseId: chosen.warehouseId,
|
||||
yardId: chosen.yardId,
|
||||
zoneId: chosen.zoneId,
|
||||
path: undefined as string | undefined,
|
||||
}
|
||||
: null;
|
||||
|
||||
const criteria = await this.getInventoryAllocationCriteria(item);
|
||||
const ruleLocation = await this.allocation.resolveLocation(criteria);
|
||||
const location = ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
const ruleLocation = manualLocation ? null : await this.allocation.resolveLocation(criteria);
|
||||
const location =
|
||||
manualLocation ?? ruleLocation ?? (await this.pickCapacityBalancedStorageLocation(item, criteria));
|
||||
|
||||
if (!location) {
|
||||
throw new BadRequestException('No active warehouse yard/zone is available for this inventory item');
|
||||
@@ -2154,18 +2219,19 @@ export class WarehouseInventoryService {
|
||||
await this.applyCapacityDelta(manager, location, weight, volume, containerCount);
|
||||
}
|
||||
|
||||
const storedReason = manualLocation
|
||||
? `Stored at operator-selected location -> ${location.path ?? 'chosen yard/zone'}`
|
||||
: ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`;
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'STORED',
|
||||
storedAt: new Date(),
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
notes: this.appendNote(
|
||||
locked.notes,
|
||||
ruleLocation?.rule
|
||||
? `Stored by allocation rule "${ruleLocation.rule.name}" -> ${ruleLocation.path}`
|
||||
: `Stored by capacity-balanced allocation -> ${location.path ?? 'assigned yard/zone'}`,
|
||||
),
|
||||
notes: this.appendNote(locked.notes, storedReason),
|
||||
});
|
||||
|
||||
await this.activityLog.record(
|
||||
@@ -2173,9 +2239,7 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_STORED',
|
||||
inventoryId: id,
|
||||
warehouseId: location.warehouseId,
|
||||
description: ruleLocation?.rule
|
||||
? `Inventory stored by rule "${ruleLocation.rule.name}" at ${ruleLocation.path}`
|
||||
: `Inventory stored at ${location.path ?? 'assigned yard/zone'}`,
|
||||
description: storedReason.replace(/^Stored/, 'Inventory stored'),
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
@@ -2294,6 +2358,26 @@ export class WarehouseInventoryService {
|
||||
'Customer must sign the handover before the exit paper can be generated',
|
||||
);
|
||||
}
|
||||
|
||||
// Authoritative weight match: the truck's net (gross − tare) must equal the
|
||||
// total VGM cargo weight of the containers selected as loaded on it.
|
||||
if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) {
|
||||
const selected = dto.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 expected = selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0);
|
||||
const computedNet = Number((dto.grossWeight - dto.tareWeight).toFixed(3));
|
||||
if (expected > 0 && Math.abs(computedNet - expected) > 0.001) {
|
||||
throw new BadRequestException(
|
||||
`Weight mismatch: gross − tare (${computedNet} t) must equal the selected containers' cargo weight (${expected} t).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
@@ -2528,6 +2612,7 @@ export class WarehouseInventoryService {
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}>
|
||||
> {
|
||||
const rows: Array<{
|
||||
@@ -2574,6 +2659,10 @@ export class WarehouseInventoryService {
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
// Booking-level gate: the per-truck exit paper is blocked until the handover
|
||||
// is fully signed, so the UI can disable "Exit Paper" with a clear reason.
|
||||
const handoverSigned = await this.handover.isFullySigned(bookingId);
|
||||
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
goods: r.goods,
|
||||
@@ -2596,6 +2685,32 @@ export class WarehouseInventoryService {
|
||||
bookingReference: r.bookingReference,
|
||||
contractId: r.contractId,
|
||||
hasLastMile: r.hasLastMile,
|
||||
handoverSigned,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's containers with their VGM cargo weight (tonnes), keyed by
|
||||
* container number. Drives the truck-leaving exit weighing: the selected
|
||||
* containers' total cargo weight must match (gross − tare).
|
||||
*/
|
||||
async bookingContainerWeights(
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> {
|
||||
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber",
|
||||
COALESCE(bcu.vgm_tons, 0) AS "weightTons"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL
|
||||
ORDER BY bcu.container_number`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
weightTons: Number(r.weightTons) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { OnEvent } from "@nestjs/event-emitter";
|
||||
import { Freight } from "@edr/types";
|
||||
import { Freight, NotificationAudience, NotificationType } from "@edr/types";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||
|
||||
import {
|
||||
BillingService,
|
||||
InvoiceEventPayload,
|
||||
@@ -135,6 +137,7 @@ export class WarehouseInvoiceService {
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) { }
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -968,6 +971,25 @@ export class WarehouseInvoiceService {
|
||||
message,
|
||||
`warehouse fee invoice ${invoice.invoiceNumber}`,
|
||||
);
|
||||
|
||||
// In-app deep-link to pay the fee from the booking.
|
||||
if (invoice.customerId && invoice.bookingId) {
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: invoice.customerId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
title: "Warehouse fee due",
|
||||
body:
|
||||
`Warehouse ${invoice.invoiceType.replace(/_/g, " ").toLowerCase()} fee ${invoice.invoiceNumber} is due — ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Pay from the portal before cargo pickup.`,
|
||||
link: `/bookings/${invoice.bookingId}`,
|
||||
data: { bookingId: invoice.bookingId, invoiceNumber: invoice.invoiceNumber },
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.warn(`In-app warehouse fee notify failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
@@ -75,6 +76,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
|
||||
@@ -64,8 +64,8 @@ export class WarehousesService {
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
status: dto.status ?? 'ACTIVE',
|
||||
isActive: (dto.status ?? 'ACTIVE') === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Crown,
|
||||
Container,
|
||||
Boxes,
|
||||
FlaskConical,
|
||||
Layers,
|
||||
Ruler,
|
||||
Scale,
|
||||
Sparkles,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
|
||||
import {
|
||||
simulateBatch,
|
||||
limitsFromDetail,
|
||||
type BlockingAxis,
|
||||
type ForecastRow,
|
||||
} from "./batchForecast";
|
||||
|
||||
type Props = {
|
||||
data: BatchBoardScheduleDetail;
|
||||
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
|
||||
};
|
||||
|
||||
const cardVar = (color: string, shade: number) =>
|
||||
`var(--mantine-color-${color}-${shade})`;
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const AXIS_LABEL: Record<BlockingAxis, string> = {
|
||||
wagons: "wagon slots full",
|
||||
weight: "over max pull weight",
|
||||
length: "over train length",
|
||||
};
|
||||
|
||||
/** One capacity axis as a labelled meter (used vs cap). */
|
||||
function AxisMeter({
|
||||
icon: Icon,
|
||||
label,
|
||||
used,
|
||||
cap,
|
||||
fmt,
|
||||
color,
|
||||
}: {
|
||||
icon: typeof Scale;
|
||||
label: string;
|
||||
used: number;
|
||||
cap: number | null;
|
||||
fmt: (n: number) => string;
|
||||
color: string;
|
||||
}) {
|
||||
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
||||
const near = pct >= 90;
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 150 }}>
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Icon size={13} color={cardVar(color, 6)} />
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
|
||||
{fmt(used)}
|
||||
{cap != null ? ` / ${fmt(cap)}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={near ? color : "edr-green"}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FreightIcon({ type }: { type: string | null }) {
|
||||
const Icon = type === "BULK" ? Boxes : Container;
|
||||
return (
|
||||
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
|
||||
function ForecastCard({ row }: { row: ForecastRow }) {
|
||||
const { booking, rank, selected, blockedBy } = row;
|
||||
const gov = booking.isGovernment;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: selected
|
||||
? cardVar("edr-green", 3)
|
||||
: cardVar("gray", 2),
|
||||
background: selected
|
||||
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
|
||||
: "var(--mantine-color-white)",
|
||||
opacity: selected ? 1 : 0.92,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
size={32}
|
||||
radius="xl"
|
||||
variant={selected && rank <= 3 ? "filled" : "light"}
|
||||
color={gov ? "grape" : selected ? "edr-green" : "gray"}
|
||||
style={{ flexShrink: 0, fontWeight: 800 }}
|
||||
>
|
||||
{gov ? (
|
||||
<Crown size={15} />
|
||||
) : (
|
||||
<Text fw={800} size="sm">
|
||||
{rank}
|
||||
</Text>
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<FreightIcon type={booking.freightType} />
|
||||
{gov ? (
|
||||
<Tooltip label="Government — boards first" withArrow>
|
||||
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
|
||||
<Crown size={10} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
|
||||
{/* score */}
|
||||
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
|
||||
<Trophy size={12} color={cardVar("edr-green", 6)} />
|
||||
<Text fw={800} size="sm" c="edr-green.7">
|
||||
{booking.priorityScore}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* wagons + weight this booking adds */}
|
||||
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
|
||||
<TrainFront size={13} color={cardVar("gray", 6)} />
|
||||
<Text fw={700} size="sm">
|
||||
{booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" w={64} ta="right">
|
||||
{fmtTons(booking.weightTons)}
|
||||
</Text>
|
||||
{/* verdict */}
|
||||
<Box w={150} style={{ textAlign: "right" }}>
|
||||
{selected ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<Sparkles size={11} />}
|
||||
>
|
||||
Would board
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
blockedBy
|
||||
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
|
||||
: "Below the capacity line"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Waiting list
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut line between the simulated batch and the simulated waiting list. */
|
||||
function CutLine({ full }: { full: boolean }) {
|
||||
return (
|
||||
<Group gap="xs" my={2} wrap="nowrap">
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
|
||||
<Layers size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="orange.7">
|
||||
Forecast capacity line{full ? " · TRAIN FULL" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
|
||||
* current pool and shows the projected winners + waiting list BEFORE document
|
||||
* review closes. Not the real selection — the engine commits that when staff run
|
||||
* the batch after the review window ends.
|
||||
*/
|
||||
export function ForecastPanel({ data, bookings }: Props) {
|
||||
const limits = useMemo(() => limitsFromDetail(data), [data]);
|
||||
const sim = useMemo(
|
||||
() => simulateBatch(bookings, limits),
|
||||
[bookings, limits],
|
||||
);
|
||||
|
||||
const noCaps =
|
||||
limits.maxWagons == null &&
|
||||
limits.maxWeightTons == null &&
|
||||
limits.maxLengthMeters == null;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header + explainer */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
|
||||
<FlaskConical size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Group gap={8}>
|
||||
<Text fw={700}>Forecast batch (simulated)</Text>
|
||||
<Badge variant="light" color="violet" radius="sm" size="sm">
|
||||
Preview
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" maw={520}>
|
||||
What the batch engine would pick if it ran now — greedy fill by
|
||||
priority until the train is full. The real selection happens when
|
||||
document review ends and staff run the batch.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="lg">
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xl" fw={800} c="edr-green.7">
|
||||
{sim.selected.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
would board
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xl" fw={800} c="gray.7">
|
||||
{sim.waiting.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
waiting list
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Three capacity axes */}
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<AxisMeter
|
||||
icon={TrainFront}
|
||||
label="Wagon slots"
|
||||
used={sim.usedWagons}
|
||||
cap={limits.maxWagons}
|
||||
fmt={(n) => `${n}`}
|
||||
color="edr-green"
|
||||
/>
|
||||
<AxisMeter
|
||||
icon={Scale}
|
||||
label="Max pull weight"
|
||||
used={sim.usedWeightTons}
|
||||
cap={limits.maxWeightTons}
|
||||
fmt={fmtTons}
|
||||
color="orange"
|
||||
/>
|
||||
<AxisMeter
|
||||
icon={Ruler}
|
||||
label="Train length"
|
||||
used={sim.usedLengthMeters}
|
||||
cap={limits.maxLengthMeters}
|
||||
fmt={fmtMeters}
|
||||
color="blue"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{noCaps ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
mt="md"
|
||||
radius="md"
|
||||
icon={<XCircle size={16} />}
|
||||
>
|
||||
No locomotive / capacity limits on this schedule yet — forecast can't
|
||||
draw the capacity line. Assign a locomotive to simulate the fill.
|
||||
</Alert>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{sim.rows.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
No eligible bookings to forecast yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{/* WOULD BOARD */}
|
||||
{sim.selected.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Projected batch{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({sim.selected.length}) — top priority, fits capacity
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{sim.selected.map((r) => (
|
||||
<ForecastCard key={r.booking.id} row={r} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<CutLine full={sim.full} />
|
||||
|
||||
{/* WAITING LIST */}
|
||||
{sim.waiting.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Layers size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Projected waiting list{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({sim.waiting.length}) — boards only if a slot frees up
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{sim.waiting.map((r) => (
|
||||
<ForecastCard key={r.booking.id} row={r} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* INELIGIBLE (expired / pending contract) */}
|
||||
{sim.ineligible.length > 0 ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{sim.ineligible.length} booking
|
||||
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
|
||||
(expired or contract not signed).
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForecastPanel;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -15,9 +16,11 @@ import {
|
||||
Clock,
|
||||
Container,
|
||||
Crown,
|
||||
FlaskConical,
|
||||
Hourglass,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Radio,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
@@ -30,6 +33,8 @@ import type {
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
import { ForecastPanel } from "./ForecastPanel";
|
||||
import { forecastIsLive } from "./batchForecast";
|
||||
|
||||
/**
|
||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||
@@ -266,6 +271,15 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
const phase = data.windowPhase;
|
||||
const isPayPhase = phase === "PAYMENT";
|
||||
|
||||
// Before the batch is committed (pre-window / open / doc-review) the real
|
||||
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
|
||||
// Default to it while it's live; let staff flip to the current live state.
|
||||
const forecastAvailable = forecastIsLive(phase);
|
||||
const [view, setView] = useState<"forecast" | "live">(
|
||||
forecastAvailable ? "forecast" : "live",
|
||||
);
|
||||
const showForecast = forecastAvailable && view === "forecast";
|
||||
|
||||
// Rank exactly as the batch engine does: government first, then priority score
|
||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||
// backend uses). The board already returns them in this order, but re-sort
|
||||
@@ -320,8 +334,51 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
|
||||
let rankNo = 0;
|
||||
|
||||
const viewToggle = forecastAvailable ? (
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "forecast" | "live")}
|
||||
size="sm"
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
value: "forecast",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FlaskConical size={13} />
|
||||
<Text size="xs" fw={600}>
|
||||
Forecast
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "live",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Radio size={13} />
|
||||
<Text size="xs" fw={600}>
|
||||
Live state
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (showForecast) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
|
||||
<ForecastPanel data={data} bookings={ranked} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
|
||||
{/* Header: phase + capacity meter */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Group justify="space-between" wrap="wrap" gap="md">
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* Client-side forecast of what the batch engine WOULD select if it ran right now.
|
||||
*
|
||||
* The real selection only happens once the document-review window closes and staff
|
||||
* hit "run batch". Before that, operations can only see the *current* per-booking
|
||||
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
|
||||
* the board can show the likely winners + waiting list live, during OPEN and
|
||||
* DOC_REVIEW, before anything is committed.
|
||||
*
|
||||
* It mirrors the engine (booking-batch.service): rank government-first, then
|
||||
* priority score desc, then oldest booked; greedily board each booking while it
|
||||
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
|
||||
* train length (metres). The first booking that busts any axis, and everyone after
|
||||
* it, drops to the waiting list. Purely a projection; the server stays the source
|
||||
* of truth for the real run.
|
||||
*/
|
||||
|
||||
export interface ForecastLimits {
|
||||
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
|
||||
maxWagons: number | null;
|
||||
/** Locomotive max pull weight in tons, or null. */
|
||||
maxWeightTons: number | null;
|
||||
/** Max train length in metres, or null. */
|
||||
maxLengthMeters: number | null;
|
||||
}
|
||||
|
||||
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
|
||||
export type BlockingAxis = "wagons" | "weight" | "length";
|
||||
|
||||
export interface ForecastRow {
|
||||
booking: BatchBoardBookingDetail;
|
||||
/** 1-based rank across the whole eligible pool. */
|
||||
rank: number;
|
||||
/** True → boards in the simulated batch; false → simulated waiting list. */
|
||||
selected: boolean;
|
||||
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
|
||||
cumulativeWagons: number;
|
||||
cumulativeWeightTons: number;
|
||||
cumulativeLengthMeters: number;
|
||||
/** If not selected, the first axis that would have overflowed. */
|
||||
blockedBy: BlockingAxis | null;
|
||||
}
|
||||
|
||||
export interface ForecastResult {
|
||||
rows: ForecastRow[];
|
||||
selected: ForecastRow[];
|
||||
waiting: ForecastRow[];
|
||||
/** Bookings excluded from the sim entirely (expired / no signed contract). */
|
||||
ineligible: BatchBoardBookingDetail[];
|
||||
limits: ForecastLimits;
|
||||
/** Totals of the simulated batch. */
|
||||
usedWagons: number;
|
||||
usedWeightTons: number;
|
||||
usedLengthMeters: number;
|
||||
/** True once any axis is at/over its cap — train is "full" in the sim. */
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Engine rank order: government first, then priority desc, then oldest booked. */
|
||||
export function rankBookings(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
): BatchBoardBookingDetail[] {
|
||||
const time = (b: BatchBoardBookingDetail) =>
|
||||
b.fullyExecutedAt
|
||||
? new Date(b.fullyExecutedAt).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (b.priorityScore !== a.priorityScore)
|
||||
return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can compete in the batch only once its contract is signed. Expired
|
||||
* bookings and pending-contract bookings never board, so they're pulled out of the
|
||||
* sim (surfaced separately so they don't vanish from the board).
|
||||
*/
|
||||
function isEligible(b: BatchBoardBookingDetail): boolean {
|
||||
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
|
||||
function overflows(used: number, add: number, cap: number | null): boolean {
|
||||
return cap != null && used + add > cap;
|
||||
}
|
||||
|
||||
export function simulateBatch(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
limits: ForecastLimits,
|
||||
): ForecastResult {
|
||||
const ranked = rankBookings(bookings);
|
||||
const eligible = ranked.filter(isEligible);
|
||||
const ineligible = ranked.filter((b) => !isEligible(b));
|
||||
|
||||
const rows: ForecastRow[] = [];
|
||||
let wagons = 0;
|
||||
let weight = 0;
|
||||
let length = 0;
|
||||
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
|
||||
let full = false;
|
||||
|
||||
eligible.forEach((booking, i) => {
|
||||
let blockedBy: BlockingAxis | null = null;
|
||||
if (!full) {
|
||||
if (overflows(wagons, booking.wagons, limits.maxWagons))
|
||||
blockedBy = "wagons";
|
||||
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
|
||||
blockedBy = "weight";
|
||||
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
|
||||
blockedBy = "length";
|
||||
}
|
||||
// Strict fill: the first booking that doesn't fit closes the train, so lower-
|
||||
// priority bookings can't leapfrog it even if they'd individually fit. Matches
|
||||
// the engine's greedy pass.
|
||||
const selected = !full && blockedBy === null;
|
||||
if (selected) {
|
||||
wagons += booking.wagons;
|
||||
weight = round2(weight + booking.weightTons);
|
||||
length = round2(length + booking.lengthMeters);
|
||||
} else {
|
||||
full = true;
|
||||
}
|
||||
rows.push({
|
||||
booking,
|
||||
rank: i + 1,
|
||||
selected,
|
||||
cumulativeWagons: selected ? wagons : 0,
|
||||
cumulativeWeightTons: selected ? weight : 0,
|
||||
cumulativeLengthMeters: selected ? length : 0,
|
||||
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
selected: rows.filter((r) => r.selected),
|
||||
waiting: rows.filter((r) => !r.selected),
|
||||
ineligible,
|
||||
limits,
|
||||
usedWagons: wagons,
|
||||
usedWeightTons: weight,
|
||||
usedLengthMeters: length,
|
||||
full,
|
||||
};
|
||||
}
|
||||
|
||||
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
|
||||
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
|
||||
if (limits.maxWagons != null) return "wagons";
|
||||
if (limits.maxWeightTons != null) return "weight";
|
||||
return "length";
|
||||
}
|
||||
|
||||
/** Pull the three capacity caps off the board detail response. */
|
||||
export function limitsFromDetail(
|
||||
data: BatchBoardScheduleDetail,
|
||||
): ForecastLimits {
|
||||
return {
|
||||
maxWagons: data.capacity.maxWagons ?? null,
|
||||
maxWeightTons:
|
||||
data.capacity.maxWeightTons ??
|
||||
data.locomotive?.maxPullWeightTons ??
|
||||
null,
|
||||
maxLengthMeters:
|
||||
data.capacity.maxLengthMeters ??
|
||||
data.locomotive?.maxTrainLengthMeters ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The forecast is meaningful before the batch is committed — i.e. while bookings
|
||||
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
|
||||
* real per-booking state is the truth, so we stop showing the projection.
|
||||
*/
|
||||
export function forecastIsLive(
|
||||
phase: BatchBoardScheduleDetail["windowPhase"],
|
||||
): boolean {
|
||||
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText } from 'lucide-react';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type ContainerItem,
|
||||
type ContainerItemStage,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ContainerItemsModalProps {
|
||||
@@ -90,12 +91,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||
});
|
||||
|
||||
const requestSign = async () => {
|
||||
try {
|
||||
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||
if (res.alreadySigned) {
|
||||
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
||||
} else {
|
||||
toast({
|
||||
title: 'Handover not signed',
|
||||
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||
try {
|
||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,15 +182,27 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{i.truckAssignmentId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
||||
<Tooltip
|
||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||
disabled={i.handoverSigned}
|
||||
withArrow
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={i.handoverSigned ? 'orange' : 'gray'}
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() =>
|
||||
i.handoverSigned
|
||||
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
||||
: requestSign()
|
||||
}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Warehouse updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||
toast({ title: 'Warehouse created' });
|
||||
}
|
||||
onClose();
|
||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
|
||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
/** Inventory table + all lifecycle actions (advance / move / history). */
|
||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const stored = await storeMutation.mutateAsync(item.id);
|
||||
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||
toast({
|
||||
title: 'Inventory stored',
|
||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
case 'ready-for-loading':
|
||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||
case 'load':
|
||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
opened={Boolean(reserveItem)}
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackageSearch,
|
||||
@@ -61,7 +65,6 @@ import type {
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||
color="yellow"
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Ready Pickup
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck_dispatch
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||
>
|
||||
Deliver
|
||||
</Button>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Tooltip label="Storage / fee preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
<PackageCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||
Store…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus !== 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||
Move…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||
Ready for pickup
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!r.hasAssignedTruck}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.hasAssignedTruck
|
||||
? r.releaseOrderReference
|
||||
? 'Truck leaving'
|
||||
: 'Truck arrival'
|
||||
: 'Truck arrival — assign a truck first'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
||||
Exit paper
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
||||
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
Storage / fee preview
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
History
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
<TruckDispatchModal
|
||||
opened={Boolean(loadTruckItem)}
|
||||
onClose={() => setLoadTruckItem(null)}
|
||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ContainerItemsModal
|
||||
opened={Boolean(containerItemsItem)}
|
||||
onClose={() => setContainerItemsItem(null)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
||||
].map(([powerPlate, trailerPlate], index) => ({
|
||||
value: powerPlate,
|
||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
||||
trailerPlate,
|
||||
}));
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||
// total cargo weight of the containers selected as loaded on it.
|
||||
const { data: containerWeights = [] } = useQuery({
|
||||
queryKey: ['release-container-weights', bookingId],
|
||||
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
const [reference, setReference] = useState('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
truckType: t.truckType,
|
||||
})),
|
||||
];
|
||||
const truckSelectOptions = [
|
||||
...assignedTruckOptions,
|
||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
trailerPlate: t.trailerPlate,
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
truckType: '',
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
// operator types the plate manually in the field below.
|
||||
const truckSelectOptions = assignedTruckOptions;
|
||||
// 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);
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
|
||||
// Which containers ride this truck, and their combined cargo weight. When the
|
||||
// booking has container weights, that sum is the authoritative net; the
|
||||
// operator selects the containers loaded on the truck at exit.
|
||||
const hasContainerWeights = containerWeights.length > 0;
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSelectData = containerWeights.map((c) => ({
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
}));
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
.toFixed(3),
|
||||
);
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
: item?.weight == null
|
||||
? netWeight
|
||||
: Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
@@ -336,23 +341,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
placeholder="Select truck or type plate manually below"
|
||||
searchable
|
||||
clearable
|
||||
data={truckSelectOptions}
|
||||
disabled={isTruckIdentityLocked}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
{truckSelectOptions.length > 0 && (
|
||||
<Select
|
||||
label="Assigned first / last-mile truck"
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
data={truckSelectOptions}
|
||||
disabled={isTruckIdentityLocked}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
@@ -376,30 +383,51 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Group grow align="flex-start">
|
||||
{hasContainerWeights ? (
|
||||
<MultiSelect
|
||||
label="Containers on this truck"
|
||||
description={
|
||||
isExitStep
|
||||
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||
: 'Containers this truck will carry.'
|
||||
}
|
||||
placeholder="Select containers"
|
||||
searchable
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Recorded net weight (system t)" min={0} value={systemNetWeight} readOnly />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
value={systemNetWeight}
|
||||
readOnly
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an unloaded import item. The operator may pick warehouse → yard → zone
|
||||
* explicitly; leaving them blank falls back to the backend auto allocation.
|
||||
*/
|
||||
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId },
|
||||
enabled: Boolean(warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId },
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const isManual = Boolean(warehouseId || yardId || zoneId);
|
||||
const manualComplete = Boolean(warehouseId && yardId && zoneId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (isManual && !manualComplete) {
|
||||
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
|
||||
blank to let the system auto-allocate by rule / available capacity.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Auto-allocate"
|
||||
searchable
|
||||
clearable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId || null}
|
||||
onChange={(v) => {
|
||||
setWarehouseId(v ?? '');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!warehouseId}
|
||||
data={yardOptions}
|
||||
value={yardId || null}
|
||||
onChange={(v) => {
|
||||
setYardId(v ?? '');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
|
||||
{manualComplete ? 'Store here' : 'Store (auto)'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
|
||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
|
||||
*/
|
||||
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const text = await responseData.text();
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
const raw = parsed?.message ?? parsed?.error;
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
if (raw) return String(raw);
|
||||
} catch {
|
||||
/* not JSON — fall through */
|
||||
}
|
||||
}
|
||||
return extractErrorMessage(error, fallback);
|
||||
};
|
||||
|
||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
useUpdateAllocationRule,
|
||||
useUpdateFeeRule,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
@@ -121,8 +125,10 @@ function AllocationRules() {
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const update = useUpdateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -142,7 +148,8 @@ function AllocationRules() {
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -153,6 +160,22 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: AllocationRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
priority: rule.priority ?? 100,
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerStatus: rule.containerStatus ?? '',
|
||||
targetYardCode: rule.targetYardCode ?? '',
|
||||
storageType: rule.storageType ?? '',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
@@ -160,7 +183,7 @@ function AllocationRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
@@ -170,10 +193,20 @@ function AllocationRules() {
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Allocation rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -182,7 +215,7 @@ function AllocationRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -229,14 +262,19 @@ function AllocationRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -245,7 +283,7 @@ function AllocationRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
@@ -340,11 +378,11 @@ function AllocationRules() {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -363,8 +401,10 @@ function FeeRules() {
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
@@ -394,7 +434,8 @@ function FeeRules() {
|
||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
@@ -410,6 +451,27 @@ function FeeRules() {
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: FeeRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerType: rule.containerType ?? '',
|
||||
vehicleType: rule.vehicleType ?? '',
|
||||
freeDays: rule.freeDays ?? 3,
|
||||
freeHours: rule.freeHours ?? 3,
|
||||
ratePerDay: rule.ratePerDay ?? 0,
|
||||
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
|
||||
currency: rule.currency ?? 'USD',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
@@ -479,12 +541,17 @@ function FeeRules() {
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Fee rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
if (!editingId && tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
@@ -505,7 +572,7 @@ function FeeRules() {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,7 +582,7 @@ function FeeRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -577,14 +644,19 @@ function FeeRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -593,7 +665,7 @@ function FeeRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -775,11 +847,11 @@ function FeeRules() {
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -99,6 +99,7 @@ import type {
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
PayInvoicePayload,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
@@ -1051,10 +1052,13 @@ export const api = {
|
||||
() => [["warehouse-inventory"], ["warehouses"]],
|
||||
),
|
||||
|
||||
store: endpoint<string, WarehouseInventoryItem>(
|
||||
store: endpoint<
|
||||
{ id: string; payload?: StoreInventoryPayload },
|
||||
WarehouseInventoryItem
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"store",
|
||||
(id) => warehouseService.store(id).then((r) => r.data),
|
||||
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
@@ -77,6 +78,7 @@ export interface ContainerItem {
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -135,6 +137,26 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
|
||||
requestHandoverSignature: async (
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
|
||||
);
|
||||
return data?.data ?? data;
|
||||
},
|
||||
|
||||
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||
getContainerWeights: async (
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Booking container numbers not yet loaded onto any truck. */
|
||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
@@ -235,8 +257,8 @@ export const warehouseService = {
|
||||
}),
|
||||
|
||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||
store: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
||||
store: (id: string, payload?: StoreInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
|
||||
reserve: (payload: ReserveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||
markReadyForLoading: (id: string) =>
|
||||
|
||||
@@ -283,6 +283,8 @@ export interface BatchBoardSchedule {
|
||||
maxLengthMeters: number | null;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
|
||||
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||
|
||||
export type InventoryAction =
|
||||
| 'store'
|
||||
| 'reserve'
|
||||
| 'ready-for-loading'
|
||||
| 'load'
|
||||
| 'dispatch'
|
||||
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
||||
UNLOADED: 'store',
|
||||
UNLOADED_AT_DJIBOUTI_PORT: null,
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
STORED: 'ready-for-loading',
|
||||
RESERVED: 'ready-for-loading',
|
||||
ARRIVED_AT_WAREHOUSE: null,
|
||||
UNDER_INSPECTION: null,
|
||||
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
// Import goods skip storage; they need inspection before pickup.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. Import STORED is handled via the import queue.
|
||||
if (isImport) return null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/** Optional explicit storage location; blank → backend auto-allocates. */
|
||||
export interface StoreInventoryPayload {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -63,14 +63,19 @@ export function CustomerTruckAssignmentCard({
|
||||
const [driverName, setDriverName] = useState("");
|
||||
const [truckType, setTruckType] = useState("");
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||
);
|
||||
// When editing a truck, its own containers stay selectable.
|
||||
const editingOwn = new Set(
|
||||
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
|
||||
);
|
||||
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||
(n) => !assignedNumbers.has(n),
|
||||
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
|
||||
);
|
||||
|
||||
// Both import and export specify the containers each truck carries.
|
||||
@@ -79,24 +84,38 @@ export function CustomerTruckAssignmentCard({
|
||||
setDriverName("");
|
||||
setTruckType("");
|
||||
setContainers([]);
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const startEdit = (t: Freight.ICustomerTruck) => {
|
||||
setPlateNumber(t.plateNumber ?? "");
|
||||
setDriverName(t.driverName ?? "");
|
||||
setTruckType(t.truckType ?? "");
|
||||
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
||||
setEditingId(t.id);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
customerTrucksService.add(booking.id, {
|
||||
mutationFn: () => {
|
||||
const payload = {
|
||||
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumbers: containers,
|
||||
}),
|
||||
};
|
||||
return editingId
|
||||
? customerTrucksService.update(booking.id, editingId, payload)
|
||||
: customerTrucksService.add(booking.id, payload);
|
||||
},
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
toast.success(editingId ? "Truck updated" : "Truck added");
|
||||
resetForm();
|
||||
onAssigned();
|
||||
toast.success("Truck added");
|
||||
},
|
||||
onError: (e) => setError(errorMessage(e, "Could not add truck")),
|
||||
onError: (e) => setError(errorMessage(e, editingId ? "Could not update truck" : "Could not add truck")),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
@@ -183,15 +202,25 @@ export function CustomerTruckAssignmentCard({
|
||||
</Group>
|
||||
</Stack>
|
||||
{!t.arrivedAt && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove truck"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
aria-label="Edit truck"
|
||||
onClick={() => startEdit(t)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove truck"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
@@ -206,7 +235,7 @@ export function CustomerTruckAssignmentCard({
|
||||
{/* Add-truck form — both directions assign the containers each truck carries. */}
|
||||
{availableContainers.length > 0 ? (
|
||||
<>
|
||||
<Divider label="Add a truck" labelPosition="center" />
|
||||
<Divider label={editingId ? "Edit truck" : "Add a truck"} labelPosition="center" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<TextInput
|
||||
label="Truck Plate Number"
|
||||
@@ -241,13 +270,18 @@ export function CustomerTruckAssignmentCard({
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
{editingId && (
|
||||
<Button variant="default" onClick={resetForm} disabled={addMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
leftSection={editingId ? <Pencil size={16} /> : <Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={submitAdd}
|
||||
loading={addMutation.isPending}
|
||||
>
|
||||
Add truck
|
||||
{editingId ? "Save changes" : "Add truck"}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -455,8 +455,6 @@ function PriceConfirmModal({
|
||||
const hasPairingBlock = pairingErrors.length > 0;
|
||||
const capacityErrors = validation?.capacityErrors ?? [];
|
||||
const hasCapacityBlock = capacityErrors.length > 0;
|
||||
const confirmDisabled =
|
||||
loading || validationLoading || hasPairingBlock || hasCapacityBlock;
|
||||
|
||||
// Authoritative server breakdown — the SAME BookingPricingService pass that
|
||||
// prices the booking on create, so it carries every line the booking will be
|
||||
@@ -479,6 +477,17 @@ function PriceConfirmModal({
|
||||
};
|
||||
}, [validation, baseTotal]);
|
||||
|
||||
// Block confirm until the authoritative server price is in hand. The client
|
||||
// baseTotal fallback is display-only; booking on it (e.g. after a validation
|
||||
// error clears validationLoading with no data) would let the customer confirm
|
||||
// an un-validated, possibly wrong price.
|
||||
const confirmDisabled =
|
||||
loading ||
|
||||
validationLoading ||
|
||||
hasPairingBlock ||
|
||||
hasCapacityBlock ||
|
||||
!serverTotal;
|
||||
|
||||
// Fallback while the server preview loads: the contract's frozen unit rates
|
||||
// (container/bulk + hazard/reefer only) with the overweight surcharge folded
|
||||
// in. Replaced by the full server breakdown the moment it arrives.
|
||||
|
||||
@@ -23,6 +23,15 @@ export const customerTrucksService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
update: async (
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
payload: Freight.AddCustomerTruckPayload,
|
||||
): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await client.patch(B.CUSTOMER_TRUCK(bookingId, assignmentId), payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
remove: async (
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { Passenger as PassengerTypes } from '@edr/types';
|
||||
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { WsAuthService } from './ws-auth.service';
|
||||
|
||||
/**
|
||||
@@ -34,27 +33,24 @@ export class SupportGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly wsAuth: WsAuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
constructor(private readonly wsAuth: WsAuthService) {}
|
||||
|
||||
async handleConnection(socket: Socket): Promise<void> {
|
||||
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||
|
||||
// Authenticated: passenger (own room) or backoffice staff (shared room).
|
||||
// A valid token means a backoffice agent: the portal connects only with a
|
||||
// device/guest id (never a token), so every token-authed socket is staff.
|
||||
// Join the shared backoffice room — no passenger-row heuristic needed.
|
||||
if (userId) {
|
||||
socket.data.userId = userId;
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: userId },
|
||||
socket.data.side = 'AGENT';
|
||||
await socket.join(SupportGateway.BACKOFFICE_ROOM);
|
||||
socket.emit('support:hello', {
|
||||
side: 'AGENT',
|
||||
room: SupportGateway.BACKOFFICE_ROOM,
|
||||
userId,
|
||||
});
|
||||
if (passenger) {
|
||||
await socket.join(`user:${userId}`);
|
||||
socket.data.side = 'USER';
|
||||
} else {
|
||||
await socket.join(SupportGateway.BACKOFFICE_ROOM);
|
||||
socket.data.side = 'AGENT';
|
||||
}
|
||||
this.logger.debug(`support socket ${socket.id} → AGENT (backoffice)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,20 @@ export function useSupportSocket(
|
||||
},
|
||||
);
|
||||
|
||||
// Temporary diagnostics — remove once live delivery is confirmed.
|
||||
socket.on('connect', () =>
|
||||
console.warn('[support] agent socket connected', socket.id),
|
||||
);
|
||||
socket.on('connect_error', (err) =>
|
||||
console.warn('[support] agent socket connect_error:', err.message),
|
||||
);
|
||||
socket.on('disconnect', (reason) =>
|
||||
console.warn('[support] agent socket disconnected:', reason),
|
||||
);
|
||||
socket.on('support:hello', (info) =>
|
||||
console.warn('[support] server assigned:', info),
|
||||
);
|
||||
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
||||
|
||||
Reference in New Issue
Block a user