mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge pull request #1373 from Tria-plc/freight_feature/usermanagement
feat(clearance): preview charge documents before and after upload
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
import { adHocLabel } from './clearance.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's typed document name travels to the API inside the multipart
|
||||||
|
* field code (`custom_<slug>_<n>`) — the only channel a part has — and comes
|
||||||
|
* back out here for GL's review grid. Mirror of `adHocSlug` in the portal's
|
||||||
|
* useClearanceFlow.
|
||||||
|
*/
|
||||||
|
const adHocSlug = (name: string) =>
|
||||||
|
name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 60);
|
||||||
|
|
||||||
|
const roundTrip = (typed: string) => adHocLabel(`custom_${adHocSlug(typed)}_17877000000000`);
|
||||||
|
|
||||||
|
describe('adHocLabel', () => {
|
||||||
|
it('recovers the name the customer typed', () => {
|
||||||
|
expect(roundTrip('Special permit')).toBe('Special permit');
|
||||||
|
expect(roundTrip('Fumigation Certificate')).toBe('Fumigation certificate');
|
||||||
|
expect(roundTrip('bank slip #2')).toBe('Bank slip 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when there is no name to show, so callers use the filename', () => {
|
||||||
|
expect(roundTrip('')).toBeNull();
|
||||||
|
// Legacy uploads keyed `custom_<timestamp>_<n>` carry no name — without the
|
||||||
|
// digits guard this would surface "1755780000000" as the document label.
|
||||||
|
expect(adHocLabel('custom_1755780000000_0')).toBeNull();
|
||||||
|
expect(adHocLabel('commercial_invoice')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,6 +57,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
exchangeService as never,
|
exchangeService as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,6 +334,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
: [],
|
: [],
|
||||||
}),
|
}),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||||||
@@ -396,11 +398,14 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||||||
rateValue: 40,
|
rateValue: 40,
|
||||||
} as Rate;
|
} as Rate;
|
||||||
|
// No serviceType relation on the booking (like the GL/portal shipment
|
||||||
|
// preview) — the flag must be resolved from serviceTypeId.
|
||||||
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
||||||
|
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
|
||||||
|
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
|
||||||
|
};
|
||||||
const result = await service.computePriceForBooking(
|
const result = await service.computePriceForBooking(
|
||||||
containerBooking({
|
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
|
||||||
serviceType: { includesCustoms: true, includesEthiopianCustomsOnly: true },
|
|
||||||
} as never),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
||||||
@@ -574,6 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
|
|||||||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||||||
}),
|
}),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||||||
@@ -704,6 +710,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
|||||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{ findById: jest.fn() } as never,
|
{ findById: jest.fn() } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const booking = (
|
const booking = (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
|
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
@@ -84,6 +85,7 @@ export class BookingPricingService {
|
|||||||
private readonly exchangeService: ExchangeService,
|
private readonly exchangeService: ExchangeService,
|
||||||
private readonly containerValidationService: ContainerValidationService,
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
private readonly cargoTypesService: CargoTypesService,
|
private readonly cargoTypesService: CargoTypesService,
|
||||||
|
private readonly serviceTypesService: ServiceTypesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
@@ -1061,8 +1063,17 @@ export class BookingPricingService {
|
|||||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
||||||
|
|
||||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||||
// contract froze its snapshots under the matching code prefix.
|
// contract froze its snapshots under the matching code prefix. Resolved by
|
||||||
const customsType = booking.serviceType?.includesEthiopianCustomsOnly
|
// id when the relation isn't loaded — the GL / portal shipment previews
|
||||||
|
// price a transient booking object, and a missing relation must not
|
||||||
|
// silently quote the standard fee the created booking is then billed
|
||||||
|
// differently for.
|
||||||
|
const serviceType =
|
||||||
|
booking.serviceType ??
|
||||||
|
(booking.serviceTypeId
|
||||||
|
? await this.serviceTypesService.findById(booking.serviceTypeId).catch(() => null)
|
||||||
|
: null);
|
||||||
|
const customsType = serviceType?.includesEthiopianCustomsOnly
|
||||||
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
? 'ETHIOPIAN_CUSTOMS_CLEARANCE'
|
||||||
: 'CUSTOMS_CLEARANCE';
|
: 'CUSTOMS_CLEARANCE';
|
||||||
const customsLabel =
|
const customsLabel =
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ContainerValidationService } from './container-validation.service';
|
|||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import {
|
import {
|
||||||
|
adHocLabel,
|
||||||
clearanceCodesForBooking,
|
clearanceCodesForBooking,
|
||||||
clearanceDocumentsOpen,
|
clearanceDocumentsOpen,
|
||||||
} from './clearance.util';
|
} from './clearance.util';
|
||||||
@@ -742,7 +743,9 @@ export class BookingTransitionService {
|
|||||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
documents.push({
|
documents.push({
|
||||||
fileKey: f.code,
|
fileKey: f.code,
|
||||||
label: f.name,
|
// What the customer called it, falling back to the filename for rows
|
||||||
|
// uploaded before the name was carried through.
|
||||||
|
label: f.title || adHocLabel(f.code) || f.name,
|
||||||
required: false,
|
required: false,
|
||||||
uploadedBy: "customer",
|
uploadedBy: "customer",
|
||||||
settingCode: "custom",
|
settingCode: "custom",
|
||||||
@@ -890,6 +893,10 @@ export class BookingTransitionService {
|
|||||||
resource: "bookings",
|
resource: "bookings",
|
||||||
code: file.fieldname,
|
code: file.fieldname,
|
||||||
file,
|
file,
|
||||||
|
// Ad-hoc uploads carry the name the customer typed (fieldname
|
||||||
|
// `custom_<label>_<n>`); it is what GL sees in the review grid instead
|
||||||
|
// of a raw filename like "scan_003.pdf".
|
||||||
|
title: adHocLabel(file.fieldname),
|
||||||
});
|
});
|
||||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||||
const settingCode = file.fieldname.startsWith("custom_")
|
const settingCode = file.fieldname.startsWith("custom_")
|
||||||
|
|||||||
@@ -146,3 +146,20 @@ export function clearanceDocumentsOpen(booking: Booking): boolean {
|
|||||||
if (booking.paymentStatus === 'PAID') return false;
|
if (booking.paymentStatus === 'PAID') return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The label the customer typed for an ad-hoc clearance document, recovered from
|
||||||
|
* its file code. The portal encodes it as `custom_<slug>_<n>`; a plain
|
||||||
|
* `custom_<n>` (older uploads, or an unnamed row) yields null so callers fall
|
||||||
|
* back to the filename.
|
||||||
|
*/
|
||||||
|
export function adHocLabel(fileKey: string): string | null {
|
||||||
|
const m = /^custom_(.+)_\d+$/.exec(fileKey);
|
||||||
|
if (!m) return null;
|
||||||
|
// Legacy keys are `custom_<timestamp>_<n>`, which this regex reads as a label
|
||||||
|
// of digits. Those carry no name — reject them so the caller falls back to
|
||||||
|
// the filename instead of showing "1755780000000".
|
||||||
|
if (/^\d+$/.test(m[1])) return null;
|
||||||
|
const label = m[1].replace(/-/g, ' ').trim();
|
||||||
|
return label ? label.charAt(0).toUpperCase() + label.slice(1) : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type RiskAssignmentRecord,
|
type RiskAssignmentRecord,
|
||||||
} from './entities/clearance-milestone.entity';
|
} from './entities/clearance-milestone.entity';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
import { adHocLabel, clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
@@ -268,7 +268,9 @@ export class BookingClearanceService {
|
|||||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
documents.push({
|
documents.push({
|
||||||
fileKey: f.code,
|
fileKey: f.code,
|
||||||
label: f.name,
|
// What the customer called it, falling back to the filename for rows
|
||||||
|
// uploaded before the name was carried through.
|
||||||
|
label: f.title || adHocLabel(f.code) || f.name,
|
||||||
required: false,
|
required: false,
|
||||||
uploadedBy: 'customer',
|
uploadedBy: 'customer',
|
||||||
settingCode: 'custom',
|
settingCode: 'custom',
|
||||||
@@ -345,7 +347,8 @@ export class BookingClearanceService {
|
|||||||
} catch {
|
} catch {
|
||||||
train = null;
|
train = null;
|
||||||
}
|
}
|
||||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
// Removed from the clearance flow — see gl-operations.service.
|
||||||
|
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
milestones.find((m) => m.milestoneCode === code);
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ export class ContractClearanceService {
|
|||||||
|
|
||||||
let train: ClearanceTrainState | null = null;
|
let train: ClearanceTrainState | null = null;
|
||||||
let bookingMilestones: ClearanceMilestone[] = [];
|
let bookingMilestones: ClearanceMilestone[] = [];
|
||||||
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
// Removed from the clearance flow — see gl-operations.service. Kept in the
|
||||||
|
// payload (always null) so existing consumers keep type-checking.
|
||||||
|
const finalInvoice: ClearanceFinalInvoiceSummary | null = null;
|
||||||
if (cycle?.bookingId) {
|
if (cycle?.bookingId) {
|
||||||
try {
|
try {
|
||||||
train = await this.glOperationsService.trainState(cycle.bookingId);
|
train = await this.glOperationsService.trainState(cycle.bookingId);
|
||||||
@@ -340,7 +342,6 @@ export class ContractClearanceService {
|
|||||||
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
bookingMilestones = await this.workflowService.listMilestonesForBooking(
|
||||||
cycle.bookingId,
|
cycle.bookingId,
|
||||||
);
|
);
|
||||||
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
|
|
||||||
}
|
}
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
|
|||||||
@@ -779,7 +779,15 @@ export class GlOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
/**
|
||||||
|
* Final-invoice state joined with its document + slip files.
|
||||||
|
*
|
||||||
|
* RETIRED from the clearance flow: the post-offload GL Djibouti invoice is no
|
||||||
|
* longer part of the export process, is not rendered on either desk or the
|
||||||
|
* portal, and never gated anything downstream. The endpoints and this reader
|
||||||
|
* stay so already-issued invoices remain resolvable; nothing calls it from a
|
||||||
|
* clearance view any more.
|
||||||
|
*/
|
||||||
async finalInvoiceSummary(
|
async finalInvoiceSummary(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||||||
|
|||||||
@@ -1218,12 +1218,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
schedule.originStationId,
|
schedule.originStationId,
|
||||||
budget.stops,
|
budget.stops,
|
||||||
);
|
);
|
||||||
const ledger = new WagonStockLedger(
|
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
|
||||||
stock.remainingByTypeId,
|
|
||||||
Math.max(1, budget.stops.length - 1),
|
|
||||||
stock.byYardId,
|
|
||||||
budget.stops,
|
|
||||||
);
|
|
||||||
// On a multi-yard consist the pool that matters is the one standing at
|
// On a multi-yard consist the pool that matters is the one standing at
|
||||||
// the booking's own boarding yard — a type carried only in Mojo must not
|
// the booking's own boarding yard — a type carried only in Mojo must not
|
||||||
// be advertised to a customer boarding at Dire.
|
// be advertised to a customer boarding at Dire.
|
||||||
@@ -4782,18 +4777,36 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private async stockLedgerFor(
|
private async stockLedgerFor(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
budget: CorridorBudget,
|
budget: CorridorBudget,
|
||||||
|
excludeBookingIds?: string[],
|
||||||
): Promise<WagonStockLedger> {
|
): Promise<WagonStockLedger> {
|
||||||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
schedule.originStationId,
|
schedule.originStationId,
|
||||||
budget.stops,
|
budget.stops,
|
||||||
);
|
);
|
||||||
return new WagonStockLedger(
|
const ledger = new WagonStockLedger(
|
||||||
stock.remainingByTypeId,
|
stock.remainingByTypeId,
|
||||||
Math.max(1, budget.stops.length - 1),
|
Math.max(1, budget.stops.length - 1),
|
||||||
stock.byYardId,
|
stock.byYardId,
|
||||||
budget.stops,
|
budget.stops,
|
||||||
);
|
);
|
||||||
|
// Debit what is already committed, per boarding yard and wagon type — the
|
||||||
|
// same bookings the corridor budget subtracted. A booking with no resolvable
|
||||||
|
// wagon type still occupies steel, so it drains any type at its yard.
|
||||||
|
const [wagonDims, allowed] = await Promise.all([
|
||||||
|
this.loadWagonDims(),
|
||||||
|
this.loadAllowedWagonTypeIds(),
|
||||||
|
]);
|
||||||
|
const anyType = [...stock.remainingByTypeId.keys()];
|
||||||
|
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||||
|
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
|
||||||
|
ledger.consume(
|
||||||
|
typeIds.length ? typeIds : anyType,
|
||||||
|
this.wagonsFor(b, wagonDims),
|
||||||
|
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ledger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4956,6 +4969,29 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||||||
const stops = await this.stopsForSchedule(schedule);
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||||
|
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||||
|
budget.subtract(
|
||||||
|
this.needFor(b, wagonDims),
|
||||||
|
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every booking already holding capacity on the schedule: allocated (linked),
|
||||||
|
* live-reserved (unexpired pay window or paid), and pending export requests
|
||||||
|
* that named this train. The ONE list both the abstract corridor budget and
|
||||||
|
* the per-yard wagon-type ledger must debit — when only the budget saw them,
|
||||||
|
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
|
||||||
|
* still advertised "15 free" there, because the whole-train budget had room
|
||||||
|
* left on that edge (from the other yard's wagons) and the ledger was born
|
||||||
|
* full.
|
||||||
|
*/
|
||||||
|
private async committedBookings(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
excludeBookingIds?: string[],
|
||||||
|
): Promise<Booking[]> {
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.filter((b): b is Booking => Boolean(b));
|
||||||
@@ -4988,13 +5024,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
relations: ['bookingContainers'],
|
relations: ['bookingContainers'],
|
||||||
})
|
})
|
||||||
).filter((b) => !excludeBookingIds?.includes(b.id));
|
).filter((b) => !excludeBookingIds?.includes(b.id));
|
||||||
for (const b of [...allocated, ...reserved, ...pendingHolds]) {
|
// A booking can sit in more than one set (allocated AND still reserved);
|
||||||
budget.subtract(
|
// it holds its wagons once.
|
||||||
this.needFor(b, wagonDims),
|
const seen = new Set<string>();
|
||||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
|
||||||
);
|
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
|
||||||
}
|
seen.add(b.id);
|
||||||
return budget;
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { CorridorBudget } from './corridor-capacity.util';
|
||||||
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: a train with 15 wagons planned at Mojo and a 15-wagon booking
|
||||||
|
* already committed from Mojo advertised "15 free at Mojo" — the whole-train
|
||||||
|
* corridor budget still had room on that edge (GMP's wagons), and the per-yard
|
||||||
|
* stock ledger was born full. The ledger must be debited by the SAME committed
|
||||||
|
* bookings the budget subtracts.
|
||||||
|
*/
|
||||||
|
describe('BookingBatchService — per-yard stock ledger debits committed bookings', () => {
|
||||||
|
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
|
||||||
|
const booking = (id: string, originYardId: string, wagonsRequired: number) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
freightType: 'BULK',
|
||||||
|
cargoTypeId: 'ct-coffee',
|
||||||
|
wagonsRequired,
|
||||||
|
originYardId,
|
||||||
|
destinationYardId: DCT,
|
||||||
|
cargoTotalWeightVgm: 1,
|
||||||
|
bookingContainers: [],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
const schedule = {
|
||||||
|
id: 'S-35',
|
||||||
|
routeId: 'route-1',
|
||||||
|
originStationId: GMP,
|
||||||
|
destinationStationId: DCT,
|
||||||
|
scheduleBookings: [{ booking: booking('BK-118', MOJO, 15) }, { booking: booking('BK-120', GMP, 1) }],
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
const makeService = (pendingHolds: Booking[] = []) => {
|
||||||
|
const milestoneRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{ yardId: GMP, sequenceNo: 1 },
|
||||||
|
{ yardId: MOJO, sequenceNo: 2 },
|
||||||
|
{ yardId: DCT, sequenceNo: 3 },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const emptyRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
// Booking.find is only used for OPERATION_REQUEST_PENDING export holds.
|
||||||
|
const bookingRepo = { find: jest.fn().mockResolvedValue(pendingHolds) };
|
||||||
|
const dataSource = {
|
||||||
|
getRepository: jest.fn((entity: unknown) =>
|
||||||
|
entity === RouteMilestone ? milestoneRepo : entity === Booking ? bookingRepo : emptyRepo,
|
||||||
|
),
|
||||||
|
query: jest.fn(async (sql: string) =>
|
||||||
|
sql.includes('cargo_type_wagon_types') ? [{ typeId: 'ct-coffee', wagonTypeId: 'nw5' }] : [],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const trainSchedulingService = {
|
||||||
|
wagonStockForSchedule: jest.fn().mockResolvedValue({
|
||||||
|
mode: 'TRAIN',
|
||||||
|
remainingByTypeId: new Map([['nw5', 46]]),
|
||||||
|
codesByTypeId: new Map([['nw5', 'NW5']]),
|
||||||
|
byYardId: new Map([
|
||||||
|
[GMP, new Map([['nw5', 31]])],
|
||||||
|
[MOJO, new Map([['nw5', 15]])],
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return new BookingBatchService(
|
||||||
|
dataSource as never,
|
||||||
|
{ findReservedForSchedule: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
trainSchedulingService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const budget = () =>
|
||||||
|
new CorridorBudget([GMP, MOJO, DCT], {
|
||||||
|
wagons: 46,
|
||||||
|
weightTons: Number.POSITIVE_INFINITY,
|
||||||
|
lengthMeters: Number.POSITIVE_INFINITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows 0 free at Mojo once its 15 planned wagons are booked, while GMP keeps its own', async () => {
|
||||||
|
const service = makeService() as unknown as {
|
||||||
|
stockLedgerFor: BookingBatchService['stockLedgerFor'];
|
||||||
|
};
|
||||||
|
const b = budget();
|
||||||
|
const ledger = await service.stockLedgerFor(schedule, b);
|
||||||
|
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(0);
|
||||||
|
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a pending export request already holds its wagons at its yard (before staff accept)', async () => {
|
||||||
|
const service = makeService([booking('BK-REQ', MOJO, 10)]) as unknown as {
|
||||||
|
stockLedgerFor: BookingBatchService['stockLedgerFor'];
|
||||||
|
};
|
||||||
|
const emptySchedule = { ...(schedule as object), scheduleBookings: [] } as never;
|
||||||
|
const b = budget();
|
||||||
|
const ledger = await service.stockLedgerFor(emptySchedule, b);
|
||||||
|
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(5);
|
||||||
|
expect(ledger.availableFor(['nw5'], b.legOf(GMP, DCT)!)).toBe(31);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes the booking being evaluated so a request never blocks its own accept', async () => {
|
||||||
|
const service = makeService() as unknown as {
|
||||||
|
stockLedgerFor: BookingBatchService['stockLedgerFor'];
|
||||||
|
};
|
||||||
|
const b = budget();
|
||||||
|
const ledger = await service.stockLedgerFor(schedule, b, ['BK-118']);
|
||||||
|
expect(ledger.availableFor(['nw5'], b.legOf(MOJO, DCT)!)).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,20 +28,30 @@ export function resolveContainerNumber(unit: ContainerUnitForPlacement): string
|
|||||||
export function autoFillPlacements(
|
export function autoFillPlacements(
|
||||||
units: ContainerUnitForPlacement[],
|
units: ContainerUnitForPlacement[],
|
||||||
containerSlots: number[],
|
containerSlots: number[],
|
||||||
|
/**
|
||||||
|
* TEU already taken per slot sequenceNo by placements the caller supplied.
|
||||||
|
* Without it a partial auto-fill restarted at wagon #1 and stacked a second
|
||||||
|
* 40ft onto a wagon another booking's placement had already filled.
|
||||||
|
*/
|
||||||
|
occupiedTeuBySlot: ReadonlyMap<number, number> = new Map(),
|
||||||
): ContainerPlacementInput[] {
|
): ContainerPlacementInput[] {
|
||||||
if (!units.length || !containerSlots.length) return [];
|
if (!units.length || !containerSlots.length) return [];
|
||||||
|
|
||||||
const placements: ContainerPlacementInput[] = [];
|
const placements: ContainerPlacementInput[] = [];
|
||||||
const MAX_TEU_PER_WAGON = 2;
|
const MAX_TEU_PER_WAGON = 2;
|
||||||
let currentSlotIndex = 0;
|
let currentSlotIndex = 0;
|
||||||
let teuInCurrentSlot = 0;
|
let teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[0]!) ?? 0;
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
const teu = unit.teuSlots ?? (unit.sizeFt && unit.sizeFt >= 40 ? 2 : 1);
|
||||||
|
|
||||||
if (teuInCurrentSlot > 0 && teuInCurrentSlot + teu > MAX_TEU_PER_WAGON) {
|
while (
|
||||||
|
teuInCurrentSlot > 0 &&
|
||||||
|
teuInCurrentSlot + teu > MAX_TEU_PER_WAGON &&
|
||||||
|
currentSlotIndex < containerSlots.length - 1
|
||||||
|
) {
|
||||||
currentSlotIndex += 1;
|
currentSlotIndex += 1;
|
||||||
teuInCurrentSlot = 0;
|
teuInCurrentSlot = occupiedTeuBySlot.get(containerSlots[currentSlotIndex]!) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sequenceNo =
|
const sequenceNo =
|
||||||
@@ -62,6 +72,25 @@ export function autoFillPlacements(
|
|||||||
return placements;
|
return placements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TEU per slot sequenceNo consumed by the given placements. */
|
||||||
|
export function occupiedTeuBySlot(
|
||||||
|
placements: ReadonlyArray<{ bookingContainerId: string; unitIndex: number; sequenceNo: number }>,
|
||||||
|
units: ContainerUnitForPlacement[],
|
||||||
|
): Map<number, number> {
|
||||||
|
const teuOfUnit = new Map(
|
||||||
|
units.map((u) => [
|
||||||
|
`${u.bookingContainerId}:${u.unitIndex}`,
|
||||||
|
u.teuSlots ?? (u.sizeFt && u.sizeFt >= 40 ? 2 : 1),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const out = new Map<number, number>();
|
||||||
|
for (const p of placements) {
|
||||||
|
const teu = teuOfUnit.get(`${p.bookingContainerId}:${p.unitIndex}`) ?? 1;
|
||||||
|
out.set(p.sequenceNo, (out.get(p.sequenceNo) ?? 0) + teu);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function findMissingContainerNumberIssues(
|
export function findMissingContainerNumberIssues(
|
||||||
units: ContainerUnitForPlacement[],
|
units: ContainerUnitForPlacement[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ export class AssignBookingsDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
forceAssign?: boolean;
|
forceAssign?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Linked bookings the plan cannot seat stay linked as WAITING_FOR_WAGON instead of failing the whole allocation (auto-allocation mode). Requested bookingIds still fail loudly.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
keepDeferredLinked?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
|
@ApiPropertyOptional({ type: [ContainerPlacementDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ import {
|
|||||||
isPlaceholderContainerNumber,
|
isPlaceholderContainerNumber,
|
||||||
placementsForBookings,
|
placementsForBookings,
|
||||||
type ContainerUnitForPlacement,
|
type ContainerUnitForPlacement,
|
||||||
|
occupiedTeuBySlot,
|
||||||
} from '../container-placement.util';
|
} from '../container-placement.util';
|
||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||||
@@ -1996,7 +1997,11 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (unplacedUnits.length) {
|
if (unplacedUnits.length) {
|
||||||
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
|
||||||
const generated = autoFillPlacements(unplacedUnits, slots);
|
const generated = autoFillPlacements(
|
||||||
|
unplacedUnits,
|
||||||
|
slots,
|
||||||
|
occupiedTeuBySlot(containerPlacements ?? [], units),
|
||||||
|
);
|
||||||
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
@@ -2053,16 +2058,23 @@ export class TrainSchedulingService {
|
|||||||
// Linked ride-alongs count as requested too: silently dropping one here is
|
// Linked ride-alongs count as requested too: silently dropping one here is
|
||||||
// exactly the delete-and-recreate orphan this method must never produce.
|
// exactly the delete-and-recreate orphan this method must never produce.
|
||||||
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
const plannedIds = new Set(validation.bookings.map((b) => b.id));
|
||||||
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
|
const dropped = allBookingIds.filter((id) => !plannedIds.has(id));
|
||||||
|
const reasonById = new Map(
|
||||||
|
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
||||||
|
);
|
||||||
|
const detailOf = (id: string) =>
|
||||||
|
reasonById.get(id) ?? `${id}: does not fit the train's wagon stock or capacity`;
|
||||||
|
// Auto-allocation (keepDeferredLinked): an ALREADY-LINKED booking the plan
|
||||||
|
// cannot seat — e.g. it boards at a yard whose planned wagons are all taken
|
||||||
|
// — stays linked and is parked WAITING_FOR_WAGON for staff, instead of
|
||||||
|
// aborting the whole rebuild and leaving every OTHER paid booking without a
|
||||||
|
// wagon too. Explicitly requested ids still fail loudly.
|
||||||
|
const keptDeferredIds = dto.keepDeferredLinked
|
||||||
|
? dropped.filter((id) => !dto.bookingIds.includes(id))
|
||||||
|
: [];
|
||||||
|
const droppedRequested = dropped.filter((id) => !keptDeferredIds.includes(id));
|
||||||
if (droppedRequested.length) {
|
if (droppedRequested.length) {
|
||||||
const reasonById = new Map(
|
const details = droppedRequested.map(detailOf);
|
||||||
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
|
|
||||||
);
|
|
||||||
const details = droppedRequested.map(
|
|
||||||
(id) =>
|
|
||||||
reasonById.get(id) ??
|
|
||||||
`${id}: does not fit the train's wagon stock or capacity`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: `Cannot allocate — ${details.join('; ')}`,
|
message: `Cannot allocate — ${details.join('; ')}`,
|
||||||
violations: details,
|
violations: details,
|
||||||
@@ -2072,6 +2084,9 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
|
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
|
||||||
|
for (const id of keptDeferredIds) {
|
||||||
|
warnings.push(`${detailOf(id)} — kept on the schedule, waiting for a wagon`);
|
||||||
|
}
|
||||||
const totalWeightTons = validation.summary.totalWeightTons;
|
const totalWeightTons = validation.summary.totalWeightTons;
|
||||||
const totalLengthMeters = validation.summary.totalLengthMeters;
|
const totalLengthMeters = validation.summary.totalLengthMeters;
|
||||||
|
|
||||||
@@ -2179,10 +2194,10 @@ export class TrainSchedulingService {
|
|||||||
wagonPlan,
|
wagonPlan,
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduleBookingRecords = bookings.map((booking) => ({
|
const scheduleBookingRecords = [
|
||||||
trainScheduleId: scheduleId,
|
...bookings.map((booking) => booking.id),
|
||||||
bookingId: booking.id,
|
...keptDeferredIds,
|
||||||
}));
|
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
|
||||||
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
|
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
|
||||||
|
|
||||||
await this.persistAllocationsAndLoads(
|
await this.persistAllocationsAndLoads(
|
||||||
@@ -2208,6 +2223,13 @@ export class TrainSchedulingService {
|
|||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const bookingId of keptDeferredIds) {
|
||||||
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
|
bookingId,
|
||||||
|
{ schedulingStatus: SchedulingStatus.WaitingForWagon },
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
|
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
@@ -4928,6 +4950,7 @@ export class TrainSchedulingService {
|
|||||||
stock,
|
stock,
|
||||||
legs: legByBookingId,
|
legs: legByBookingId,
|
||||||
edgeCount: Math.max(1, stops.length - 1),
|
edgeCount: Math.max(1, stops.length - 1),
|
||||||
|
stops,
|
||||||
});
|
});
|
||||||
violations.push(...planned.configIssues);
|
violations.push(...planned.configIssues);
|
||||||
const fittingBookings = planned.fitting;
|
const fittingBookings = planned.fitting;
|
||||||
@@ -9362,8 +9385,7 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
if (!performAssign || !assignableIds.length) return result;
|
if (!performAssign || !assignableIds.length) return result;
|
||||||
|
|
||||||
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
|
if (containerBookings.some((b) => assignableSet.has(b.id)) && !assignPlacements.length) {
|
||||||
if (needsPlacements && !assignPlacements.length) {
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
violations: [...result.violations, 'Container placements could not be generated'],
|
violations: [...result.violations, 'Container placements could not be generated'],
|
||||||
@@ -9371,12 +9393,14 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// No placements handed over on purpose: the assign re-plans the WHOLE
|
||||||
|
// linked set (incl. non-eligible linked bookings such as one already in
|
||||||
|
// transit), so slot numbers from THIS preview would not line up with the
|
||||||
|
// plan it builds — it auto-fills every unit against its own plan instead.
|
||||||
|
// The preview placements above only serve the missing-number check.
|
||||||
await this.assignBookingsToSchedule(
|
await this.assignBookingsToSchedule(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
{
|
{ bookingIds: assignableIds, keepDeferredLinked: true },
|
||||||
bookingIds: assignableIds,
|
|
||||||
containerPlacements: needsPlacements ? assignPlacements : undefined,
|
|
||||||
},
|
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
result.assignedBookingIds = assignableIds;
|
result.assignedBookingIds = assignableIds;
|
||||||
@@ -10044,8 +10068,11 @@ export class TrainSchedulingService {
|
|||||||
if (!booking) return null;
|
if (!booking) return null;
|
||||||
|
|
||||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||||
|
// Count the fleet at the yard the BOOKING boards from, not the train's
|
||||||
|
// origin: on a split consist a Mojo booking can only ride Mojo wagons, and
|
||||||
|
// Gelan's 31 spare wagons told a paying customer there was no shortage.
|
||||||
const fleetCounts = await this.countFleetAvailability(
|
const fleetCounts = await this.countFleetAvailability(
|
||||||
schedule.originStationId,
|
booking.originYardId ?? schedule.originStationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
);
|
);
|
||||||
const fleetByTypeId = new Map(
|
const fleetByTypeId = new Map(
|
||||||
|
|||||||
@@ -443,3 +443,50 @@ describe('planWagonsWithStock — break-bulk (PER_ITEM) item-aware packing', ()
|
|||||||
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
expect(result.plan.map((s) => s.assignedWeightTons)).toEqual([70, 30]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('planWagonsWithStock — consist split across yards', () => {
|
||||||
|
const GMP = 'gmp', MOJO = 'mojo', DCT = 'dct';
|
||||||
|
const boards = (id: string, quantity: number, originYardId: string): Booking =>
|
||||||
|
({ ...containerBooking(id, quantity, quantity), originYardId, destinationYardId: DCT }) as Booking;
|
||||||
|
const allowed = { byContainerTypeId: new Map([['ct-1', [nw6]]]), byCargoTypeId: new Map() };
|
||||||
|
const legsFor = (bookings: Booking[]) =>
|
||||||
|
new Map(bookings.map((b) => [b.id, { from: b.originYardId === GMP ? 0 : 1, to: 2 }]));
|
||||||
|
const splitStock = {
|
||||||
|
mode: 'TRAIN' as const,
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 46]]),
|
||||||
|
codesByTypeId: new Map([[nw6.id, nw6.code]]),
|
||||||
|
byYardId: new Map([
|
||||||
|
[GMP, new Map([[nw6.id, 31]])],
|
||||||
|
[MOJO, new Map([[nw6.id, 15]])],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('seats a boarding yard only from the wagons planned there', () => {
|
||||||
|
// 20fts pack two per wagon: BKG-A's 30 boxes take all 15 Mojo wagons;
|
||||||
|
// BKG-B needs 2 more at Mojo → deferred, while BKG-C at Gelan still fits
|
||||||
|
// (the whole-train 46 is irrelevant).
|
||||||
|
const bookings = [boards('BKG-A', 30, MOJO), boards('BKG-B', 4, MOJO), boards('BKG-C', 2, GMP)];
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings, allowed, stock: splitStock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
|
||||||
|
});
|
||||||
|
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-A', 'BKG-C']);
|
||||||
|
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-B']);
|
||||||
|
expect(result.deferred[0]!.reason).toContain('planned at the boarding yard');
|
||||||
|
expect(result.plan).toHaveLength(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a Gelan 20ft share a wagon that only exists at Mojo', () => {
|
||||||
|
const stock = {
|
||||||
|
...splitStock,
|
||||||
|
remainingByTypeId: new Map([[nw6.id, 1]]),
|
||||||
|
byYardId: new Map([[MOJO, new Map([[nw6.id, 1]])]]),
|
||||||
|
};
|
||||||
|
const bookings = [boards('BKG-M', 1, MOJO), boards('BKG-G', 1, GMP)];
|
||||||
|
const result = planWagonsWithStock({
|
||||||
|
bookings, allowed, stock, legs: legsFor(bookings), edgeCount: 2, stops: [GMP, MOJO, DCT],
|
||||||
|
});
|
||||||
|
// The Mojo wagon has TEU room, but it is not standing in Gelan.
|
||||||
|
expect(result.fitting.map((b) => b.id)).toEqual(['BKG-M']);
|
||||||
|
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ type OpenSlot = {
|
|||||||
legKey: string;
|
legKey: string;
|
||||||
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
|
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
|
||||||
covered: { from: number; to: number };
|
covered: { from: number; to: number };
|
||||||
|
/**
|
||||||
|
* Boarding-yard pool this wagon was opened from — the yard the consist plans
|
||||||
|
* it at (`''` when the consist is not split across yards). A Mojo wagon
|
||||||
|
* cannot later be stretched back to board at Gelan.
|
||||||
|
*/
|
||||||
|
pool: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
|
||||||
@@ -197,9 +203,19 @@ export function planWagonsWithStock(params: {
|
|||||||
*/
|
*/
|
||||||
legs?: Map<string, BookingLeg>;
|
legs?: Map<string, BookingLeg>;
|
||||||
edgeCount?: number;
|
edgeCount?: number;
|
||||||
|
/**
|
||||||
|
* Ordered corridor stop ids, parallel to the edges. Required for a consist
|
||||||
|
* split across yards (`stock.byYardId`): a booking then draws ONLY from the
|
||||||
|
* wagons planned at the yard it boards from (`stops[leg.from]`) — the
|
||||||
|
* whole-train count would happily plan 17 Mojo wagons on a train that has
|
||||||
|
* 15 there and 31 in Gelan, and the physical pin then fails after the
|
||||||
|
* customer has paid.
|
||||||
|
*/
|
||||||
|
stops?: readonly string[];
|
||||||
}): FlexPlanResult {
|
}): FlexPlanResult {
|
||||||
const { bookings, allowed, stock, legs } = params;
|
const { bookings, allowed, stock, legs } = params;
|
||||||
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
const edgeCount = Math.max(1, params.edgeCount ?? 1);
|
||||||
|
const stops = params.stops ?? [];
|
||||||
const openSlots: OpenSlot[] = [];
|
const openSlots: OpenSlot[] = [];
|
||||||
const fitting: Booking[] = [];
|
const fitting: Booking[] = [];
|
||||||
const deferred: DeferredBookingRow[] = [];
|
const deferred: DeferredBookingRow[] = [];
|
||||||
@@ -214,32 +230,45 @@ export function planWagonsWithStock(params: {
|
|||||||
};
|
};
|
||||||
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`;
|
||||||
|
|
||||||
// Wagons of a type in use per corridor edge. A type is available for a leg
|
// Split consist: each boarding yard is its own pool of steel (mirrors
|
||||||
// when its busiest edge WITHIN that leg still has stock spare — the max over
|
// WagonStockLedger). Single-yard consist / loose yard pool: one pool ''.
|
||||||
// edges is the number of physical wagons the type needs simultaneously.
|
const poolOf = (leg: BookingLeg): string =>
|
||||||
|
stock.byYardId ? (stops[leg.from] ?? '') : '';
|
||||||
|
const rowKeyFor = (wagonTypeId: string, pool: string): string =>
|
||||||
|
pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId;
|
||||||
|
const totalFor = (wagonTypeId: string, pool: string): number =>
|
||||||
|
pool
|
||||||
|
? (stock.byYardId?.get(pool)?.get(wagonTypeId) ?? 0)
|
||||||
|
: (stock.remainingByTypeId.get(wagonTypeId) ?? 0);
|
||||||
|
|
||||||
|
// Wagons of a type in use per corridor edge, per pool. A type is available
|
||||||
|
// for a leg when its busiest edge WITHIN that leg still has stock spare — the
|
||||||
|
// max over edges is the number of physical wagons the type needs simultaneously.
|
||||||
const usedPerEdge = new Map<string, number[]>();
|
const usedPerEdge = new Map<string, number[]>();
|
||||||
const usedRow = (wagonTypeId: string): number[] => {
|
const usedRow = (key: string): number[] => {
|
||||||
let row = usedPerEdge.get(wagonTypeId);
|
let row = usedPerEdge.get(key);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
row = new Array<number>(edgeCount).fill(0);
|
row = new Array<number>(edgeCount).fill(0);
|
||||||
usedPerEdge.set(wagonTypeId, row);
|
usedPerEdge.set(key, row);
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||||
const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
const pool = poolOf(leg);
|
||||||
const row = usedPerEdge.get(wagonTypeId);
|
const total = totalFor(wagonTypeId, pool);
|
||||||
|
const row = usedPerEdge.get(rowKeyFor(wagonTypeId, pool));
|
||||||
if (!row) return total;
|
if (!row) return total;
|
||||||
let busiest = 0;
|
let busiest = 0;
|
||||||
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0);
|
||||||
return total - busiest;
|
return total - busiest;
|
||||||
};
|
};
|
||||||
|
|
||||||
const noStockMessage = (candidates: WagonType[]): string => {
|
const noStockMessage = (candidates: WagonType[], leg: BookingLeg): string => {
|
||||||
const codes = candidates.map((wt) => wt.code).join('/');
|
const codes = candidates.map((wt) => wt.code).join('/');
|
||||||
return stock.mode === 'TRAIN'
|
if (stock.mode !== 'TRAIN') return `No available ${codes} wagon at the yard`;
|
||||||
? `Train has no free ${codes} wagon left`
|
return poolOf(leg)
|
||||||
: `No available ${codes} wagon at the yard`;
|
? `Train has no free ${codes} wagon planned at the boarding yard`
|
||||||
|
: `Train has no free ${codes} wagon left`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
/** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */
|
||||||
@@ -251,7 +280,7 @@ export function planWagonsWithStock(params: {
|
|||||||
): OpenSlot | PlacementProblem => {
|
): OpenSlot | PlacementProblem => {
|
||||||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||||
if (!inStock.length) {
|
if (!inStock.length) {
|
||||||
return { kind: 'stock', message: noStockMessage(candidates), candidates };
|
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
|
||||||
}
|
}
|
||||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||||
@@ -261,7 +290,8 @@ export function planWagonsWithStock(params: {
|
|||||||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||||
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||||
)[0];
|
)[0];
|
||||||
const row = usedRow(chosen.id);
|
const pool = poolOf(leg);
|
||||||
|
const row = usedRow(rowKeyFor(chosen.id, pool));
|
||||||
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
|
||||||
const open: OpenSlot = {
|
const open: OpenSlot = {
|
||||||
slot: slotFromWagonType(chosen, kind),
|
slot: slotFromWagonType(chosen, kind),
|
||||||
@@ -271,6 +301,7 @@ export function planWagonsWithStock(params: {
|
|||||||
freeCapacityTons: Number(chosen.capacityTons),
|
freeCapacityTons: Number(chosen.capacityTons),
|
||||||
legKey: legKeyOf(leg),
|
legKey: legKeyOf(leg),
|
||||||
covered: { ...leg },
|
covered: { ...leg },
|
||||||
|
pool,
|
||||||
};
|
};
|
||||||
openSlots.push(open);
|
openSlots.push(open);
|
||||||
return open;
|
return open;
|
||||||
@@ -290,8 +321,11 @@ export function planWagonsWithStock(params: {
|
|||||||
* slot's type spare — extending the span puts this wagon on those edges.
|
* slot's type spare — extending the span puts this wagon on those edges.
|
||||||
*/
|
*/
|
||||||
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
|
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
|
||||||
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
|
// A pooled wagon boards where its yard is; it cannot be stretched back to
|
||||||
const row = usedPerEdge.get(open.slot.wagonTypeId);
|
// an EARLIER stop (the steel is not there), only ridden further.
|
||||||
|
if (open.pool && leg.from < open.covered.from) return false;
|
||||||
|
const total = totalFor(open.slot.wagonTypeId, open.pool);
|
||||||
|
const row = usedPerEdge.get(rowKeyFor(open.slot.wagonTypeId, open.pool));
|
||||||
const from = Math.min(open.covered.from, leg.from);
|
const from = Math.min(open.covered.from, leg.from);
|
||||||
const to = Math.max(open.covered.to, leg.to);
|
const to = Math.max(open.covered.to, leg.to);
|
||||||
for (let e = from; e < to; e += 1) {
|
for (let e = from; e < to; e += 1) {
|
||||||
@@ -303,7 +337,7 @@ export function planWagonsWithStock(params: {
|
|||||||
|
|
||||||
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
|
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
|
||||||
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
|
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
|
||||||
const row = usedRow(open.slot.wagonTypeId);
|
const row = usedRow(rowKeyFor(open.slot.wagonTypeId, open.pool));
|
||||||
const from = Math.min(open.covered.from, leg.from);
|
const from = Math.min(open.covered.from, leg.from);
|
||||||
const to = Math.max(open.covered.to, leg.to);
|
const to = Math.max(open.covered.to, leg.to);
|
||||||
for (let e = from; e < to; e += 1) {
|
for (let e = from; e < to; e += 1) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ArrayMinSize, IsArray, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetTrainWagonsYardDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [String],
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Coupled wagons to relocate. All move in one transaction — if any is pinned to a live schedule, none move.',
|
||||||
|
})
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
wagonIds!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Yard the selected wagons now sit in. The train itself stays put.',
|
||||||
|
})
|
||||||
|
@IsUUID()
|
||||||
|
currentYardId!: string;
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
|||||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||||
|
import { SetTrainWagonsYardDto } from './dto/set-train-wagons-yard.dto';
|
||||||
import { TrainBuilderService } from './train-builder.service';
|
import { TrainBuilderService } from './train-builder.service';
|
||||||
|
|
||||||
@ApiTags('train-builder')
|
@ApiTags('train-builder')
|
||||||
@@ -128,6 +129,25 @@ export class TrainBuilderController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id/wagons/yard')
|
||||||
|
@FleetManage(FREIGHT_PERMS.trains.changeWagonYard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule',
|
||||||
|
})
|
||||||
|
setWagonsYard(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetTrainWagonsYardDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.trainBuilderService.setWagonsYard(
|
||||||
|
id,
|
||||||
|
dto.wagonIds,
|
||||||
|
dto.currentYardId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/wagons')
|
@Post(':id/wagons')
|
||||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||||
|
|
||||||
|
/** Schedule whose FULL flag must be re-derived once the consist edit has committed. */
|
||||||
|
type PendingWindowCheck = { scheduleId: string; wasFull: boolean };
|
||||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||||
|
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
@@ -550,15 +553,79 @@ export class TrainBuilderService {
|
|||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move SEVERAL coupled wagons to another yard in one transaction (the train
|
||||||
|
* and the rest of the consist stay put). All-or-nothing: if any wagon is not
|
||||||
|
* coupled here, or is pinned to a live schedule, nothing moves — a partial
|
||||||
|
* relocation would leave the consist split across yards silently. Wagons
|
||||||
|
* already in the target yard are skipped, not an error.
|
||||||
|
*/
|
||||||
|
async setWagonsYard(
|
||||||
|
id: string,
|
||||||
|
wagonIds: string[],
|
||||||
|
currentYardId: string,
|
||||||
|
userId?: string | null,
|
||||||
|
) {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const train = await this.getEditableTrain(manager, id);
|
||||||
|
const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } });
|
||||||
|
if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`);
|
||||||
|
|
||||||
|
const unique = [...new Set(wagonIds)];
|
||||||
|
const wagons = await manager.getRepository(Wagon).find({ where: unique.map((wid) => ({ id: wid })) });
|
||||||
|
const byId = new Map(wagons.map((w) => [w.id, w]));
|
||||||
|
const missing = unique.filter((wid) => byId.get(wid)?.trainId !== train.id);
|
||||||
|
if (missing.length) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`${missing.length} of ${unique.length} wagons are not coupled to train ${train.code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check every wagon before moving any — the whole point of the bulk call.
|
||||||
|
const pinned: string[] = [];
|
||||||
|
for (const wagon of wagons) {
|
||||||
|
if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) {
|
||||||
|
pinned.push(wagon.wagonNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pinned.length) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`${pinned.join(', ')} ${pinned.length === 1 ? 'is' : 'are'} allocated to a scheduled or dispatched run; ${
|
||||||
|
pinned.length === 1 ? 'its' : 'their'
|
||||||
|
} yard cannot be changed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const moving = wagons.filter((w) => w.currentYardId !== yard.id);
|
||||||
|
if (!moving.length) return;
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.update(moving.map((w) => w.id), { currentYardId: yard.id });
|
||||||
|
await manager.getRepository(WagonMovement).save(
|
||||||
|
moving.map((w) =>
|
||||||
|
manager.getRepository(WagonMovement).create({
|
||||||
|
wagonId: w.id,
|
||||||
|
fromYardId: w.currentYardId ?? null,
|
||||||
|
toYardId: yard.id,
|
||||||
|
kind: WagonMovementKind.Manual,
|
||||||
|
movedByUserId: userId ?? null,
|
||||||
|
occurredAt: new Date(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return this.getComposition(id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
/** Append AVAILABLE, unassigned wagons (any yard) to the consist. */
|
||||||
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const currentCount = await manager
|
const currentCount = await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
.count({ where: { trainId: train.id } });
|
.count({ where: { trainId: train.id } });
|
||||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||||
await this.syncLiveScheduleAfterConsistChange(
|
return this.syncLiveScheduleAfterConsistChange(
|
||||||
manager,
|
manager,
|
||||||
train.id,
|
train.id,
|
||||||
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
|
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
|
||||||
@@ -566,12 +633,13 @@ export class TrainBuilderService {
|
|||||||
train.currentYardId ?? null,
|
train.currentYardId ?? null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await this.reconcileWindowAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach one wagon and close the sequence gap it leaves. */
|
/** Detach one wagon and close the sequence gap it leaves. */
|
||||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||||
if (!wagon || wagon.trainId !== train.id) {
|
if (!wagon || wagon.trainId !== train.id) {
|
||||||
@@ -586,7 +654,7 @@ export class TrainBuilderService {
|
|||||||
exportTrainNumber: null,
|
exportTrainNumber: null,
|
||||||
});
|
});
|
||||||
await this.resequenceWagons(manager, train.id);
|
await this.resequenceWagons(manager, train.id);
|
||||||
await this.syncLiveScheduleAfterConsistChange(
|
return this.syncLiveScheduleAfterConsistChange(
|
||||||
manager,
|
manager,
|
||||||
train.id,
|
train.id,
|
||||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||||
@@ -594,6 +662,7 @@ export class TrainBuilderService {
|
|||||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await this.reconcileWindowAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,7 +677,7 @@ export class TrainBuilderService {
|
|||||||
userId?: string | null,
|
userId?: string | null,
|
||||||
note?: string | null,
|
note?: string | null,
|
||||||
) {
|
) {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||||
if (!wagon || wagon.trainId !== train.id) {
|
if (!wagon || wagon.trainId !== train.id) {
|
||||||
@@ -663,7 +732,7 @@ export class TrainBuilderService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.resequenceWagons(manager, train.id);
|
await this.resequenceWagons(manager, train.id);
|
||||||
await this.syncLiveScheduleAfterConsistChange(
|
return this.syncLiveScheduleAfterConsistChange(
|
||||||
manager,
|
manager,
|
||||||
train.id,
|
train.id,
|
||||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||||
@@ -671,6 +740,7 @@ export class TrainBuilderService {
|
|||||||
yardId,
|
yardId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await this.reconcileWindowAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,8 +1070,8 @@ export class TrainBuilderService {
|
|||||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||||
userId: string | null,
|
userId: string | null,
|
||||||
yardId: string | null,
|
yardId: string | null,
|
||||||
): Promise<void> {
|
): Promise<PendingWindowCheck | null> {
|
||||||
if (!changes.length) return;
|
if (!changes.length) return null;
|
||||||
const trainSet = await manager
|
const trainSet = await manager
|
||||||
.getRepository(TrainSet)
|
.getRepository(TrainSet)
|
||||||
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
|
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
|
||||||
@@ -1027,7 +1097,7 @@ export class TrainBuilderService {
|
|||||||
.getRepository(TrainSet)
|
.getRepository(TrainSet)
|
||||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||||
}
|
}
|
||||||
if (!schedule) return;
|
if (!schedule) return null;
|
||||||
|
|
||||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||||
|
|
||||||
@@ -1047,16 +1117,31 @@ export class TrainBuilderService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
|
// The FULL/reopen decision must run AFTER the transaction commits — see
|
||||||
// schedule reopens its booking window; filling the last one closes it.
|
// reconcileWindowAfterConsistChange.
|
||||||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
||||||
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
|
||||||
|
* schedule reopens its booking window; filling the last one closes it.
|
||||||
|
*
|
||||||
|
* Runs only once the consist transaction has COMMITTED. BookingBatchService
|
||||||
|
* reads through its own connection, so inside the transaction it still saw
|
||||||
|
* the old consist: a wagon coupled onto an empty (FULL) train counted as 0
|
||||||
|
* slots, `nowFull` stayed true and the window was never reopened.
|
||||||
|
*/
|
||||||
|
private async reconcileWindowAfterConsistChange(
|
||||||
|
pending: PendingWindowCheck | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!pending) return;
|
||||||
|
const usage = await this.bookingBatchService.scheduleWagonUsage(pending.scheduleId);
|
||||||
if (!usage) return;
|
if (!usage) return;
|
||||||
const nowFull = usage.remainingSlots <= 0;
|
const nowFull = usage.remainingSlots <= 0;
|
||||||
if (wasFull && !nowFull) {
|
if (pending.wasFull && !nowFull) {
|
||||||
await this.bookingBatchService.refreshWindowStatus(schedule.id);
|
await this.bookingBatchService.refreshWindowStatus(pending.scheduleId);
|
||||||
} else if (!wasFull && nowFull) {
|
} else if (!pending.wasFull && nowFull) {
|
||||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
await this.bookingBatchService.setWindow(pending.scheduleId, 'FULL');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,11 @@ export class WagonsService {
|
|||||||
if (dto.currentYardId !== undefined) {
|
if (dto.currentYardId !== undefined) {
|
||||||
wagon.currentYard = null;
|
wagon.currentYard = null;
|
||||||
}
|
}
|
||||||
|
// Same trap for `wagonType`: the stale eager-loaded relation would win over
|
||||||
|
// the new `wagonTypeId` and the type change would silently not persist.
|
||||||
|
if (dto.wagonTypeId !== undefined) {
|
||||||
|
wagon.wagonType = undefined;
|
||||||
|
}
|
||||||
await this.wagonRepo.save(wagon);
|
await this.wagonRepo.save(wagon);
|
||||||
// Staff manually relocated the wagon — write the movement ledger row so the
|
// Staff manually relocated the wagon — write the movement ledger row so the
|
||||||
// wagon's yard history stays auditable (who moved it, from where, when).
|
// wagon's yard history stays auditable (who moved it, from where, when).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -59,11 +59,65 @@ const isLocked = (s: Freight.ClearanceChargeStatus) =>
|
|||||||
|
|
||||||
type BillInput = { amount: number; currency: string; description: string };
|
type BillInput = { amount: number; currency: string; description: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blob URL for a not-yet-uploaded File, so the staff member can open it in
|
||||||
|
* the shared viewer before committing the upload. Revoked whenever the pick
|
||||||
|
* changes or the form unmounts — a leaked object URL pins the whole file in
|
||||||
|
* memory for the life of the tab.
|
||||||
|
*/
|
||||||
|
function useLocalPreview(file: File | null) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file) {
|
||||||
|
setUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = URL.createObjectURL(file);
|
||||||
|
setUrl(next);
|
||||||
|
return () => URL.revokeObjectURL(next);
|
||||||
|
}, [file]);
|
||||||
|
return file && url ? { name: file.name, url, mimeType: file.type } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Preview" for a staged file — same viewer the uploaded documents open in. */
|
||||||
|
function StagedFilePreview({
|
||||||
|
file,
|
||||||
|
onViewFile,
|
||||||
|
}: {
|
||||||
|
file: File | null;
|
||||||
|
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
}) {
|
||||||
|
const preview = useLocalPreview(file);
|
||||||
|
if (!file) return null;
|
||||||
|
return (
|
||||||
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText size={15} color="var(--mantine-color-edr-green-6)" />
|
||||||
|
<Text fz="12.5px" c="edr-text" truncate style={{ minWidth: 0, maxWidth: 220 }}>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
{preview && isViewable({ name: file.name, url: "" }) ? (
|
||||||
|
<Tooltip label="Preview before uploading">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={13} />}
|
||||||
|
onClick={() => onViewFile(preview)}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClearanceChargesTabProps {
|
export interface ClearanceChargesTabProps {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
/** DJ uploads the port document; ET bills, sends and creates miscellaneous. */
|
||||||
roleMode: "ET" | "DJ";
|
roleMode: "ET" | "DJ";
|
||||||
onViewFile: (file: { name: string; url: string }) => void;
|
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,24 +225,13 @@ export function ClearanceChargesTab({
|
|||||||
onSend={() => port && send.mutate(port.id)}
|
onSend={() => port && send.mutate(port.id)}
|
||||||
djUpload={
|
djUpload={
|
||||||
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
roleMode === "DJ" && (!port || port.status === "DOC_UPLOADED") ? (
|
||||||
<FileButton
|
<PortDocumentUpload
|
||||||
onChange={(f) => f && uploadPort.mutate(f)}
|
replacing={Boolean(port)}
|
||||||
accept="application/pdf,image/*"
|
busy={busy}
|
||||||
disabled={busy}
|
uploading={uploadPort.isPending}
|
||||||
>
|
onViewFile={onViewFile}
|
||||||
{(props) => (
|
onUpload={(f) => uploadPort.mutate(f)}
|
||||||
<Button
|
/>
|
||||||
{...props}
|
|
||||||
size="compact-sm"
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Upload size={14} />}
|
|
||||||
loading={uploadPort.isPending}
|
|
||||||
>
|
|
||||||
{port ? "Replace document" : "Upload document"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -227,6 +270,7 @@ export function ClearanceChargesTab({
|
|||||||
<MiscCreateForm
|
<MiscCreateForm
|
||||||
key={miscCreated}
|
key={miscCreated}
|
||||||
busy={createMisc.isPending}
|
busy={createMisc.isPending}
|
||||||
|
onViewFile={onViewFile}
|
||||||
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
onCreate={(file, input) => createMisc.mutate({ file, ...input })}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -255,6 +299,66 @@ export function ClearanceChargesTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Port-charges document: pick, preview, then upload. The pick is staged rather
|
||||||
|
* than sent straight away so the wrong scan can be caught before it lands on
|
||||||
|
* the customer's charge.
|
||||||
|
*/
|
||||||
|
function PortDocumentUpload({
|
||||||
|
replacing,
|
||||||
|
busy,
|
||||||
|
uploading,
|
||||||
|
onViewFile,
|
||||||
|
onUpload,
|
||||||
|
}: {
|
||||||
|
replacing: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
uploading: boolean;
|
||||||
|
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={8} align="center" wrap="wrap">
|
||||||
|
<FileButton
|
||||||
|
onChange={setFile}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-sm"
|
||||||
|
variant={file ? "light" : "filled"}
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={14} />}
|
||||||
|
>
|
||||||
|
{file ? "Choose another" : replacing ? "Replace document" : "Choose document"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<StagedFilePreview file={file} onViewFile={onViewFile} />
|
||||||
|
{file ? (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={uploading}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
onUpload(file);
|
||||||
|
setFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{replacing ? "Upload replacement" : "Upload document"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ChargeCard({
|
function ChargeCard({
|
||||||
title,
|
title,
|
||||||
charge,
|
charge,
|
||||||
@@ -272,7 +376,7 @@ function ChargeCard({
|
|||||||
roleMode: "ET" | "DJ";
|
roleMode: "ET" | "DJ";
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
emptyHint: string;
|
emptyHint: string;
|
||||||
onViewFile: (file: { name: string; url: string }) => void;
|
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
onBill: (input: BillInput) => void;
|
onBill: (input: BillInput) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
djUpload?: React.ReactNode;
|
djUpload?: React.ReactNode;
|
||||||
@@ -554,9 +658,11 @@ function ChargeCard({
|
|||||||
function MiscCreateForm({
|
function MiscCreateForm({
|
||||||
busy,
|
busy,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onViewFile,
|
||||||
}: {
|
}: {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onCreate: (file: File, input: BillInput) => void;
|
onCreate: (file: File, input: BillInput) => void;
|
||||||
|
onViewFile: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
}) {
|
}) {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [amount, setAmount] = useState<number | string>("");
|
const [amount, setAmount] = useState<number | string>("");
|
||||||
@@ -585,10 +691,11 @@ function MiscCreateForm({
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Upload size={14} />}
|
leftSection={<Upload size={14} />}
|
||||||
>
|
>
|
||||||
{file ? file.name : "Choose document"}
|
{file ? "Choose another" : "Choose document"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
<StagedFilePreview file={file} onViewFile={onViewFile} />
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Amount"
|
label="Amount"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface ClearanceOpsTabsProps {
|
|||||||
*/
|
*/
|
||||||
exchangeEntityId?: string;
|
exchangeEntityId?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
onViewFile?: (file: { name: string; url: string }) => void;
|
onViewFile?: (file: { name: string; url: string; mimeType?: string | null }) => void;
|
||||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { useMemo, useState } from "react";
|
import {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
FileInput,
|
FileInput,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
|
||||||
NumberInput,
|
|
||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Stepper,
|
Stepper,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DateInput } from "@mantine/dates";
|
import {
|
||||||
|
DateInput,
|
||||||
|
} from "@mantine/dates";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -30,9 +31,12 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import {
|
||||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
SectionCard,
|
||||||
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
|
} from "@/components/bookings/detail/SectionCard";
|
||||||
|
import {
|
||||||
|
TransitAssigneePanel,
|
||||||
|
} from "@/components/contracts/TransitAssigneePanel";
|
||||||
import {
|
import {
|
||||||
TransitPermitMultiUpload,
|
TransitPermitMultiUpload,
|
||||||
type TransitPermitUploadedRow,
|
type TransitPermitUploadedRow,
|
||||||
@@ -51,8 +55,12 @@ import {
|
|||||||
type ClearanceViewLike,
|
type ClearanceViewLike,
|
||||||
type MilestoneRow,
|
type MilestoneRow,
|
||||||
} from "@/components/contracts/PhasedClearanceActionPanel";
|
} from "@/components/contracts/PhasedClearanceActionPanel";
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import {
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
contractsService,
|
||||||
|
} from "@/services/contracts.service";
|
||||||
|
import {
|
||||||
|
bookingsService,
|
||||||
|
} from "@/services/bookings.service";
|
||||||
|
|
||||||
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
|
/** Today as `yyyy-MM-dd` in the browser's local zone (a DateInput `minDate`). */
|
||||||
function todayISODate(): string {
|
function todayISODate(): string {
|
||||||
@@ -66,8 +74,11 @@ function todayISODate(): string {
|
|||||||
* customer docs → transit assignee (DJ names officer) → declaration (ET,
|
* customer docs → transit assignee (DJ names officer) → declaration (ET,
|
||||||
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
|
* releases the export) → RO (DJ, auto-releases) → create booking (ET)
|
||||||
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
||||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
|
* → accept T1 (DJ, one button after arrival) → gate pass (DJ) → offload.
|
||||||
* → final invoice (DJ) + customer slip + GL confirm.
|
*
|
||||||
|
* The post-offload GL Djibouti final invoice was removed from this flow: it
|
||||||
|
* never gated anything downstream, so the export now completes at the offload.
|
||||||
|
* Its API endpoints and any already-issued invoices are untouched.
|
||||||
*/
|
*/
|
||||||
export function computeExportActiveStep(
|
export function computeExportActiveStep(
|
||||||
clearance: ClearanceViewLike,
|
clearance: ClearanceViewLike,
|
||||||
@@ -93,11 +104,10 @@ export function computeExportActiveStep(
|
|||||||
if (!clearance.train?.arrivedAt) return 7;
|
if (!clearance.train?.arrivedAt) return 7;
|
||||||
if (!clearance.t1Closed) return 8;
|
if (!clearance.t1Closed) return 8;
|
||||||
if (!clearance.gatepassGranted) return 9;
|
if (!clearance.gatepassGranted) return 9;
|
||||||
// Step 10 is the read-only Offload step. It never gates the flow: the final
|
// Step 10 is the read-only Offload step, recorded by operations. It never
|
||||||
// invoice may be raised on a secured gate pass alone, so parking the stepper
|
// gated the flow and nothing follows it, so the stepper completes here rather
|
||||||
// there would hide the invoice actions whenever operations lag on the offload.
|
// than waiting on an offload stamp this desk does not control.
|
||||||
if (clearance.finalInvoice?.status !== "PAID") return 11;
|
return 10;
|
||||||
return 12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportTransitFilesFromWorkflow(
|
export function exportTransitFilesFromWorkflow(
|
||||||
@@ -491,27 +501,6 @@ export function ExportClearanceStepper({
|
|||||||
<OffloadStep clearance={clearance} />
|
<OffloadStep clearance={clearance} />
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|
||||||
<Stepper.Step
|
|
||||||
label="Final invoice & payment"
|
|
||||||
description="GL Djibouti invoices after offload; customer pays"
|
|
||||||
icon={
|
|
||||||
clearance.finalInvoice?.status === "PAID" ? (
|
|
||||||
<CheckCircle2 size={14} />
|
|
||||||
) : (
|
|
||||||
<Receipt size={14} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FinalInvoiceStep
|
|
||||||
bookingId={actionBookingId}
|
|
||||||
clearance={clearance}
|
|
||||||
canDjAct={showDj && canDj}
|
|
||||||
canConfirm={(showDj && canDj) || (showEt && canEt)}
|
|
||||||
onChanged={onChanged}
|
|
||||||
onViewFile={onViewFile}
|
|
||||||
onDownloadFile={onDownloadFile}
|
|
||||||
/>
|
|
||||||
</Stepper.Step>
|
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -688,230 +677,6 @@ function AcceptT1Step({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FinalInvoiceStep({
|
|
||||||
bookingId,
|
|
||||||
clearance,
|
|
||||||
canDjAct,
|
|
||||||
canConfirm,
|
|
||||||
onChanged,
|
|
||||||
onViewFile,
|
|
||||||
onDownloadFile,
|
|
||||||
}: {
|
|
||||||
bookingId: string | null;
|
|
||||||
clearance: ClearanceViewLike;
|
|
||||||
canDjAct: boolean;
|
|
||||||
canConfirm: boolean;
|
|
||||||
onChanged?: () => void;
|
|
||||||
onViewFile?: (file: { name: string; url: string }) => void;
|
|
||||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
|
||||||
}) {
|
|
||||||
const [opened, setOpened] = useState(false);
|
|
||||||
const [amount, setAmount] = useState<number | string>("");
|
|
||||||
const [currency, setCurrency] = useState("ETB");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
|
|
||||||
const invoice = clearance.finalInvoice ?? null;
|
|
||||||
const paid = invoice?.status === "PAID";
|
|
||||||
// Raised as a draft — the customer approves it before paying.
|
|
||||||
const approved = Boolean(invoice?.approvedAt);
|
|
||||||
|
|
||||||
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
|
|
||||||
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
|
|
||||||
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
|
|
||||||
return (
|
|
||||||
<StepStatus
|
|
||||||
done={false}
|
|
||||||
pendingLabel="Waiting for cargo offload (handled in operations)."
|
|
||||||
doneLabel=""
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="sm">
|
|
||||||
{invoice ? (
|
|
||||||
<Paper withBorder radius="md" p="sm">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="sm">
|
|
||||||
{invoice.invoiceNumber}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
|
||||||
{invoice.description ? ` — ${invoice.description}` : ""}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light">
|
|
||||||
{approved ? invoice.status : "AWAITING CUSTOMER APPROVAL"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{invoice?.invoiceFile ? (
|
|
||||||
<PhasedUploadedFileRow
|
|
||||||
label="Final Invoice"
|
|
||||||
file={invoice.invoiceFile}
|
|
||||||
onView={onViewFile}
|
|
||||||
onDownload={onDownloadFile}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{invoice?.slipFile ? (
|
|
||||||
<PhasedUploadedFileRow
|
|
||||||
label="Customer payment slip"
|
|
||||||
file={invoice.slipFile}
|
|
||||||
onView={onViewFile}
|
|
||||||
onDownload={onDownloadFile}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{paid ? (
|
|
||||||
<StepStatus
|
|
||||||
done
|
|
||||||
pendingLabel=""
|
|
||||||
doneLabel={`Payment confirmed${
|
|
||||||
invoice?.confirmedAt ? ` · ${new Date(invoice.confirmedAt).toLocaleString()}` : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
) : invoice ? (
|
|
||||||
<>
|
|
||||||
<StepStatus
|
|
||||||
done={false}
|
|
||||||
pendingLabel={
|
|
||||||
!approved
|
|
||||||
? "Waiting for the customer to review and approve the invoice."
|
|
||||||
: invoice.slipFile
|
|
||||||
? "Payment slip attached — confirm to settle the invoice."
|
|
||||||
: "Waiting for the customer to pay and attach the payment slip."
|
|
||||||
}
|
|
||||||
doneLabel=""
|
|
||||||
/>
|
|
||||||
{canConfirm && bookingId && invoice.slipFile ? (
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={confirming}
|
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
|
||||||
onClick={async () => {
|
|
||||||
setConfirming(true);
|
|
||||||
try {
|
|
||||||
await contractsService.confirmFinalInvoicePaid(bookingId);
|
|
||||||
toast.success("Payment confirmed — invoice settled");
|
|
||||||
onChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm payment received
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : canDjAct && bookingId ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Send the final invoice to the customer if post-arrival charges apply (optional).
|
|
||||||
The customer approves it before paying.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Receipt size={16} />}
|
|
||||||
onClick={() => setOpened(true)}
|
|
||||||
>
|
|
||||||
Send invoice
|
|
||||||
</Button>
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={() => setOpened(false)}
|
|
||||||
title={<Text fw={700}>Send final invoice</Text>}
|
|
||||||
radius="md"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group grow align="flex-start">
|
|
||||||
<NumberInput
|
|
||||||
label="Amount"
|
|
||||||
placeholder="0.00"
|
|
||||||
value={amount}
|
|
||||||
onChange={setAmount}
|
|
||||||
min={0}
|
|
||||||
thousandSeparator=","
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Currency"
|
|
||||||
data={["ETB", "USD"]}
|
|
||||||
value={currency}
|
|
||||||
onChange={(v) => setCurrency(v ?? "ETB")}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Textarea
|
|
||||||
label="Description"
|
|
||||||
placeholder="What the invoice bills for"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
|
||||||
minRows={2}
|
|
||||||
/>
|
|
||||||
<PhasedFileDropzone
|
|
||||||
label="Invoice document"
|
|
||||||
description="Any file type."
|
|
||||||
accept="*/*"
|
|
||||||
value={file}
|
|
||||||
onChange={setFile}
|
|
||||||
onPreview={onViewFile}
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setOpened(false)} disabled={sending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
loading={sending}
|
|
||||||
disabled={amount === "" || Number(amount) <= 0 || !file}
|
|
||||||
leftSection={<Upload size={16} />}
|
|
||||||
onClick={async () => {
|
|
||||||
if (!file) return;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await contractsService.sendFinalInvoice(bookingId, {
|
|
||||||
amount: Number(amount),
|
|
||||||
currency,
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
toast.success("Final invoice sent to the customer");
|
|
||||||
setOpened(false);
|
|
||||||
onChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Failed");
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Send invoice
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<StepStatus
|
|
||||||
done={false}
|
|
||||||
pendingLabel="Waiting for GL Djibouti to send the final invoice."
|
|
||||||
doneLabel=""
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReleaseOrderActions({
|
export function ReleaseOrderActions({
|
||||||
entityId,
|
entityId,
|
||||||
isBooking,
|
isBooking,
|
||||||
|
|||||||
@@ -6,10 +6,32 @@ import {
|
|||||||
type DraggableStateSnapshot,
|
type DraggableStateSnapshot,
|
||||||
type DropResult,
|
type DropResult,
|
||||||
} from "@hello-pangea/dnd";
|
} from "@hello-pangea/dnd";
|
||||||
import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
|
import { GripVertical, MapPin, Search, Trash2, Wrench, X } from "lucide-react";
|
||||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -41,25 +63,86 @@ function ConsistWagonList({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onMaintenance,
|
onMaintenance,
|
||||||
onChangeYard,
|
onChangeYard,
|
||||||
|
onChangeYardBulk,
|
||||||
busy = false,
|
busy = false,
|
||||||
}: ConsistWagonListProps) {
|
}: ConsistWagonListProps) {
|
||||||
const onDragEnd = useCallback((result: DropResult) => {
|
// Multi-select for the bulk yard move. Only offered when the page passes a
|
||||||
if (!result.destination) return;
|
// bulk handler — otherwise the checkbox column would lead nowhere.
|
||||||
const from = result.source.index;
|
const canSelect = Boolean(onChangeYardBulk) && editable;
|
||||||
const to = result.destination.index;
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
if (from === to) return;
|
const [bulkYardId, setBulkYardId] = useState<string | null>(null);
|
||||||
const next = [...wagons];
|
const bulkYardsQuery = useQuery(
|
||||||
const [moved] = next.splice(from, 1);
|
api.routes.yards.queryOptions({
|
||||||
next.splice(to, 0, moved!);
|
staleTime: 5 * 60_000,
|
||||||
onReorder(next.map((w) => w.id));
|
enabled: canSelect,
|
||||||
}, [wagons, onReorder]);
|
}),
|
||||||
|
);
|
||||||
|
// A wagon detached elsewhere must not linger in the selection.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
if (!prev.size) return prev;
|
||||||
|
const live = new Set(wagons.map((w) => w.id));
|
||||||
|
const next = new Set([...prev].filter((id) => live.has(id)));
|
||||||
|
return next.size === prev.size ? prev : next;
|
||||||
|
});
|
||||||
|
}, [wagons]);
|
||||||
|
const toggleSelected = useCallback((wagonId: string) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(wagonId)) next.delete(wagonId);
|
||||||
|
else next.add(wagonId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const clearSelection = useCallback(() => setSelected(new Set()), []);
|
||||||
|
const allSelected = canSelect && selected.size === wagons.length && wagons.length > 0;
|
||||||
|
const applyBulkYard = () => {
|
||||||
|
if (!onChangeYardBulk || !bulkYardId || !selected.size) return;
|
||||||
|
onChangeYardBulk([...selected], bulkYardId, () => {
|
||||||
|
clearSelection();
|
||||||
|
setBulkYardId(null);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Search never filters: a consist is a physical order, hiding rows would
|
||||||
|
// make position numbers lie. It scrolls the first match into view instead.
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const matchId = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
return (
|
||||||
|
wagons.find((w) => w.wagonNumber.toLowerCase().includes(q))?.id ?? null
|
||||||
|
);
|
||||||
|
}, [search, wagons]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!matchId) return;
|
||||||
|
listRef.current
|
||||||
|
?.querySelector(`[data-wagon-id="${matchId}"]`)
|
||||||
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
}, [matchId]);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(
|
||||||
|
(result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
const from = result.source.index;
|
||||||
|
const to = result.destination.index;
|
||||||
|
if (from === to) return;
|
||||||
|
const next = [...wagons];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved!);
|
||||||
|
onReorder(next.map((w) => w.id));
|
||||||
|
},
|
||||||
|
[wagons, onReorder],
|
||||||
|
);
|
||||||
|
|
||||||
// Legend of the types actually coupled, in consist order — the colour code is
|
// Legend of the types actually coupled, in consist order — the colour code is
|
||||||
// only readable if the row tints are keyed somewhere.
|
// only readable if the row tints are keyed somewhere.
|
||||||
const legend = useMemo(
|
const legend = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...new Map(
|
...new Map(
|
||||||
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
|
wagons
|
||||||
|
.filter((w) => w.wagonType)
|
||||||
|
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||||
).values(),
|
).values(),
|
||||||
],
|
],
|
||||||
[wagons],
|
[wagons],
|
||||||
@@ -74,52 +157,149 @@ function ConsistWagonList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<Stack gap="xs">
|
||||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
<TextInput
|
||||||
{(dropProvided) => (
|
size="xs"
|
||||||
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
placeholder="Find wagon number…"
|
||||||
{legend.length > 1 ? (
|
leftSection={<Search size={14} />}
|
||||||
<Group gap={6} wrap="wrap">
|
value={search}
|
||||||
{legend.map((type) => (
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
<Badge
|
error={
|
||||||
key={type.code}
|
search.trim() && !matchId
|
||||||
size="sm"
|
? "No wagon in this consist matches"
|
||||||
radius="sm"
|
: undefined
|
||||||
variant="light"
|
}
|
||||||
color={wagonTypeColor(type.code)}
|
aria-label="Find wagon in consist"
|
||||||
>
|
/>
|
||||||
{type.code} · {type.name}
|
{canSelect ? (
|
||||||
</Badge>
|
<Group justify="space-between" wrap="nowrap">
|
||||||
))}
|
<Checkbox
|
||||||
</Group>
|
size="xs"
|
||||||
) : null}
|
label={
|
||||||
{wagons.map((wagon, index) => (
|
selected.size
|
||||||
<Draggable
|
? `${selected.size} selected`
|
||||||
key={wagon.id}
|
: "Select wagons to move together"
|
||||||
draggableId={wagon.id}
|
}
|
||||||
index={index}
|
checked={allSelected}
|
||||||
isDragDisabled={!editable || busy}
|
indeterminate={selected.size > 0 && !allSelected}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={() =>
|
||||||
|
setSelected(allSelected ? new Set() : new Set(wagons.map((w) => w.id)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selected.size ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<X size={13} />}
|
||||||
|
onClick={clearSelection}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{/* Bulk bar appears only with a selection, so it never competes with the
|
||||||
|
per-wagon yard badge for attention. */}
|
||||||
|
{canSelect && selected.size ? (
|
||||||
|
<Paper withBorder p="xs" radius="md" bg="var(--mantine-color-blue-0)">
|
||||||
|
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||||
|
<Select
|
||||||
|
size="xs"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
label={`Move ${selected.size} wagon${selected.size === 1 ? "" : "s"} to yard`}
|
||||||
|
placeholder="Select yard"
|
||||||
|
data={(bulkYardsQuery.data ?? []).map((y) => ({
|
||||||
|
value: y.id,
|
||||||
|
label: y.label ?? y.code,
|
||||||
|
}))}
|
||||||
|
value={bulkYardId}
|
||||||
|
onChange={setBulkYardId}
|
||||||
|
searchable
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
leftSection={<MapPin size={14} />}
|
||||||
|
disabled={busy || !bulkYardId}
|
||||||
|
onClick={applyBulkYard}
|
||||||
|
>
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
{legend.length > 1 ? (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{legend.map((type) => (
|
||||||
|
<Badge
|
||||||
|
key={type.code}
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={wagonTypeColor(type.code)}
|
||||||
|
>
|
||||||
|
{type.code} · {type.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
|
<Droppable
|
||||||
|
droppableId="train-consist-wagons"
|
||||||
|
isDropDisabled={!editable || busy}
|
||||||
|
>
|
||||||
|
{(dropProvided) => (
|
||||||
|
<Box
|
||||||
|
ref={listRef}
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
maxHeight: 520,
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
ref={dropProvided.innerRef}
|
||||||
|
{...dropProvided.droppableProps}
|
||||||
>
|
>
|
||||||
{(dragProvided, snapshot) => (
|
{wagons.map((wagon, index) => (
|
||||||
<WagonRow
|
<Draggable
|
||||||
wagon={wagon}
|
key={wagon.id}
|
||||||
|
draggableId={wagon.id}
|
||||||
index={index}
|
index={index}
|
||||||
dragProvided={dragProvided}
|
isDragDisabled={!editable || busy}
|
||||||
snapshot={snapshot}
|
>
|
||||||
editable={editable}
|
{(dragProvided, snapshot) => (
|
||||||
busy={busy}
|
<WagonRow
|
||||||
onRemove={onRemove}
|
wagon={wagon}
|
||||||
onMaintenance={onMaintenance}
|
index={index}
|
||||||
onChangeYard={onChangeYard}
|
dragProvided={dragProvided}
|
||||||
/>
|
snapshot={snapshot}
|
||||||
)}
|
editable={editable}
|
||||||
</Draggable>
|
busy={busy}
|
||||||
))}
|
highlighted={wagon.id === matchId}
|
||||||
{dropProvided.placeholder}
|
selectable={canSelect}
|
||||||
</Stack>
|
selected={selected.has(wagon.id)}
|
||||||
)}
|
onToggleSelected={toggleSelected}
|
||||||
</Droppable>
|
onRemove={onRemove}
|
||||||
</DragDropContext>
|
onMaintenance={onMaintenance}
|
||||||
|
onChangeYard={onChangeYard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{dropProvided.placeholder}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +315,15 @@ export interface ConsistWagonListProps {
|
|||||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||||
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
|
/** Move one wagon to another yard from its yard badge; absent = read-only badge. */
|
||||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||||
|
/**
|
||||||
|
* Move every selected wagon to one yard in a single request. Absent hides the
|
||||||
|
* selection column entirely.
|
||||||
|
*/
|
||||||
|
onChangeYardBulk?: (
|
||||||
|
wagonIds: string[],
|
||||||
|
currentYardId: string,
|
||||||
|
onDone: () => void,
|
||||||
|
) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +337,23 @@ function WagonYardBadge({
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
onChange?: (wagonId: string, currentYardId: string) => void;
|
onChange?: (wagonId: string, currentYardId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
const label =
|
||||||
|
wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard";
|
||||||
const yardsQuery = useQuery(
|
const yardsQuery = useQuery(
|
||||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }),
|
api.routes.yards.queryOptions({
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
enabled: Boolean(onChange),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
if (!onChange) {
|
if (!onChange) {
|
||||||
return wagon.currentYard ? (
|
return wagon.currentYard ? (
|
||||||
<Badge variant="outline" color="gray" size="xs" radius="sm" leftSection={<MapPin size={10} />}>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<MapPin size={10} />}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -202,6 +401,10 @@ const WagonRow = memo(function WagonRow({
|
|||||||
snapshot,
|
snapshot,
|
||||||
editable,
|
editable,
|
||||||
busy,
|
busy,
|
||||||
|
highlighted,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggleSelected,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMaintenance,
|
onMaintenance,
|
||||||
onChangeYard,
|
onChangeYard,
|
||||||
@@ -212,6 +415,10 @@ const WagonRow = memo(function WagonRow({
|
|||||||
snapshot: DraggableStateSnapshot;
|
snapshot: DraggableStateSnapshot;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
highlighted: boolean;
|
||||||
|
selectable: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
onToggleSelected: (wagonId: string) => void;
|
||||||
onRemove: (wagonId: string) => void;
|
onRemove: (wagonId: string) => void;
|
||||||
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
onMaintenance: (wagon: TrainCompositionWagon) => void;
|
||||||
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
onChangeYard?: (wagonId: string, currentYardId: string) => void;
|
||||||
@@ -224,6 +431,7 @@ const WagonRow = memo(function WagonRow({
|
|||||||
ref={dragProvided.innerRef}
|
ref={dragProvided.innerRef}
|
||||||
{...dragProvided.draggableProps}
|
{...dragProvided.draggableProps}
|
||||||
{...dragProvided.dragHandleProps}
|
{...dragProvided.dragHandleProps}
|
||||||
|
data-wagon-id={wagon.id}
|
||||||
gap="sm"
|
gap="sm"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
p="sm"
|
p="sm"
|
||||||
@@ -237,11 +445,33 @@ const WagonRow = memo(function WagonRow({
|
|||||||
background: snapshot.isDragging
|
background: snapshot.isDragging
|
||||||
? "white"
|
? "white"
|
||||||
: `var(--mantine-color-${color}-0)`,
|
: `var(--mantine-color-${color}-0)`,
|
||||||
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
|
boxShadow: snapshot.isDragging
|
||||||
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
|
? "0 8px 24px rgba(0, 0, 0, 0.12)"
|
||||||
|
: highlighted
|
||||||
|
? "0 0 0 3px var(--mantine-color-yellow-4)"
|
||||||
|
: selected
|
||||||
|
? "0 0 0 2px var(--mantine-color-blue-5)"
|
||||||
|
: undefined,
|
||||||
|
cursor: editable
|
||||||
|
? snapshot.isDragging
|
||||||
|
? "grabbing"
|
||||||
|
: "grab"
|
||||||
|
: "default",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{selectable ? (
|
||||||
|
<Checkbox
|
||||||
|
size="sm"
|
||||||
|
checked={selected}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||||
|
// The row is a drag handle — keep the click on the checkbox.
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={() => onToggleSelected(wagon.id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
<Box c="dimmed" style={{ display: "flex", alignItems: "center" }}>
|
||||||
<GripVertical size={18} />
|
<GripVertical size={18} />
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||||
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions());
|
||||||
|
const setWagonsYard = useMutation(api.trainBuilder.setWagonsYard.mutationOptions());
|
||||||
const maintenanceWagon = useMutation(
|
const maintenanceWagon = useMutation(
|
||||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -165,6 +166,7 @@ export default function TrainBuilderDetailPage() {
|
|||||||
assignWagons.isPending ||
|
assignWagons.isPending ||
|
||||||
removeWagon.isPending ||
|
removeWagon.isPending ||
|
||||||
setWagonYard.isPending ||
|
setWagonYard.isPending ||
|
||||||
|
setWagonsYard.isPending ||
|
||||||
maintenanceWagon.isPending ||
|
maintenanceWagon.isPending ||
|
||||||
reorderWagons.isPending;
|
reorderWagons.isPending;
|
||||||
|
|
||||||
@@ -230,6 +232,16 @@ export default function TrainBuilderDetailPage() {
|
|||||||
},
|
},
|
||||||
[withToast, setWagonYard.mutateAsync, trainId],
|
[withToast, setWagonYard.mutateAsync, trainId],
|
||||||
);
|
);
|
||||||
|
const handleChangeWagonsYard = useCallback(
|
||||||
|
(wagonIds: string[], currentYardId: string, onDone: () => void) => {
|
||||||
|
if (!trainId) return;
|
||||||
|
void withToast(async () => {
|
||||||
|
await setWagonsYard.mutateAsync({ id: trainId, wagonIds, currentYardId });
|
||||||
|
onDone();
|
||||||
|
}, "Could not move the selected wagons");
|
||||||
|
},
|
||||||
|
[withToast, setWagonsYard.mutateAsync, trainId],
|
||||||
|
);
|
||||||
const handleMaintenance = useCallback(
|
const handleMaintenance = useCallback(
|
||||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||||
[],
|
[],
|
||||||
@@ -515,6 +527,9 @@ export default function TrainBuilderDetailPage() {
|
|||||||
onChangeYard={
|
onChangeYard={
|
||||||
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined
|
||||||
}
|
}
|
||||||
|
onChangeYardBulk={
|
||||||
|
composition.editable && canChangeWagonYard ? handleChangeWagonsYard : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2178,6 +2178,19 @@ export const api = {
|
|||||||
seedComposition,
|
seedComposition,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
setWagonsYard: endpoint<
|
||||||
|
{ id: string; wagonIds: string[]; currentYardId: string },
|
||||||
|
TrainComposition
|
||||||
|
>(
|
||||||
|
"train-builder",
|
||||||
|
"setWagonsYard",
|
||||||
|
({ id, wagonIds, currentYardId }) =>
|
||||||
|
trainBuilderService.setWagonsYard(id, wagonIds, currentYardId).then((r) => r.data),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||||
|
seedComposition,
|
||||||
|
),
|
||||||
|
|
||||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||||
"train-builder",
|
"train-builder",
|
||||||
"removeWagon",
|
"removeWagon",
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ export const trainBuilderService = {
|
|||||||
/** Move one coupled wagon to another yard; the train stays put. */
|
/** Move one coupled wagon to another yard; the train stays put. */
|
||||||
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
|
setWagonYard: (id: string, wagonId: string, currentYardId: string) =>
|
||||||
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
|
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }),
|
||||||
|
/** Move several coupled wagons to another yard in one transaction (all-or-nothing). */
|
||||||
|
setWagonsYard: (id: string, wagonIds: string[], currentYardId: string) =>
|
||||||
|
apiClient.patch<TrainComposition>(`${BASE}/${id}/wagons/yard`, {
|
||||||
|
wagonIds,
|
||||||
|
currentYardId,
|
||||||
|
}),
|
||||||
assignWagons: (id: string, wagonIds: string[]) =>
|
assignWagons: (id: string, wagonIds: string[]) =>
|
||||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||||
removeWagon: (id: string, wagonId: string) =>
|
removeWagon: (id: string, wagonId: string) =>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Button, Group, Text } from "@mantine/core";
|
import { Alert, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
Clock,
|
Clock,
|
||||||
|
FilePlus2,
|
||||||
PackagePlus,
|
PackagePlus,
|
||||||
Upload,
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import {
|
import {
|
||||||
bookingDocNoun,
|
bookingDocNoun,
|
||||||
@@ -29,7 +32,26 @@ import { CardTitle, SectionCard } from "./layout";
|
|||||||
*/
|
*/
|
||||||
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
// Opening straight onto a blank "Additional documents" row, so answering a
|
||||||
|
// GL request is one click rather than a hunt down the document grid.
|
||||||
|
const [openWithAdHoc, setOpenWithAdHoc] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
// What Global Logistics asked this customer for, if anything. Shares the
|
||||||
|
// clearance query key with the modal, so this costs no extra request.
|
||||||
|
const { data: clearance } = useQuery({
|
||||||
|
queryKey: ["booking-clearance", booking.id],
|
||||||
|
queryFn: () => bookingsService.getClearance(booking.id),
|
||||||
|
});
|
||||||
|
const docRequests = clearance?.docRequests ?? [];
|
||||||
|
const latestRequest = docRequests[0] ?? null;
|
||||||
|
const openAdHoc = () => {
|
||||||
|
setOpenWithAdHoc(true);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setOpenWithAdHoc(false);
|
||||||
|
};
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
// Self-clearance services collect the customer's own import/export paperwork,
|
// Self-clearance services collect the customer's own import/export paperwork,
|
||||||
@@ -105,6 +127,43 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* GL asked for something — red, above the fold, with the ask in their
|
||||||
|
own words and a one-click way to answer it. */}
|
||||||
|
{latestRequest && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
mb="sm"
|
||||||
|
icon={<FilePlus2 size={18} />}
|
||||||
|
title="Global Logistics needs a document from you"
|
||||||
|
>
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Text fz="13px" c="#10202F" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{latestRequest.note}
|
||||||
|
</Text>
|
||||||
|
<Text fz="11.5px" c="dimmed">
|
||||||
|
{latestRequest.byName ?? "Global Logistics"} ·{" "}
|
||||||
|
{new Date(latestRequest.at).toLocaleString()}
|
||||||
|
{docRequests.length > 1
|
||||||
|
? ` · ${docRequests.length} requests in total`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<FilePlus2 size={14} />}
|
||||||
|
onClick={openAdHoc}
|
||||||
|
>
|
||||||
|
Add document
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{summary}
|
{summary}
|
||||||
|
|
||||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||||
@@ -119,7 +178,8 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<BookingActionModal
|
<BookingActionModal
|
||||||
booking={booking}
|
booking={booking}
|
||||||
opened={modalOpen}
|
opened={modalOpen}
|
||||||
onClose={() => setModalOpen(false)}
|
startWithAdHocRow={openWithAdHoc}
|
||||||
|
onClose={closeModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -89,8 +89,7 @@ export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
const showSecond = Boolean(
|
const showSecond = Boolean(
|
||||||
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
|
clearance.secondDuty?.advised || clearance.secondDuty?.paid,
|
||||||
);
|
);
|
||||||
const showFinal = Boolean(clearance.finalInvoice);
|
if (!showDuty && !showSecond) return null;
|
||||||
if (!showDuty && !showSecond && !showFinal) return null;
|
|
||||||
|
|
||||||
const onChanged = () => void refetch();
|
const onChanged = () => void refetch();
|
||||||
|
|
||||||
@@ -126,14 +125,6 @@ export function CustomsPaymentsCard({ booking }: { booking: Freight.IBooking })
|
|||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showFinal && clearance.finalInvoice && (
|
|
||||||
<FinalInvoiceDueCard
|
|
||||||
invoice={clearance.finalInvoice}
|
|
||||||
bookingId={booking.id}
|
|
||||||
onView={view}
|
|
||||||
onChanged={onChanged}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{viewer}
|
{viewer}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -258,188 +249,6 @@ function GroupLabel({ icon: Icon, text }: { icon: typeof Receipt; text: string }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
|
|
||||||
* invoice document; the customer pays offline and attaches the payment slip
|
|
||||||
* here, then GL confirms and the badge flips to PAID.
|
|
||||||
*/
|
|
||||||
function FinalInvoiceDueCard({
|
|
||||||
invoice,
|
|
||||||
bookingId,
|
|
||||||
onView,
|
|
||||||
onChanged,
|
|
||||||
}: {
|
|
||||||
invoice: NonNullable<Freight.ClearanceView["finalInvoice"]>;
|
|
||||||
bookingId: string;
|
|
||||||
onView: (file: { name: string; url: string }) => void;
|
|
||||||
onChanged: () => void;
|
|
||||||
}) {
|
|
||||||
const [slip, setSlip] = useState<File | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [approving, setApproving] = useState(false);
|
|
||||||
const paid = invoice.status === "PAID";
|
|
||||||
// GL Djibouti raises it as a draft: nothing is payable until the customer
|
|
||||||
// reviews the attached invoice and approves it.
|
|
||||||
const approved = Boolean(invoice.approvedAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
withBorder
|
|
||||||
radius="lg"
|
|
||||||
p="lg"
|
|
||||||
style={{
|
|
||||||
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
|
|
||||||
background: paid ? "#F6FBF8" : "#FFFBF2",
|
|
||||||
position: "relative",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: 3,
|
|
||||||
background: paid ? GREEN : "#E3A93C",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
|
||||||
<div>
|
|
||||||
<Group gap={8} align="center">
|
|
||||||
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
|
|
||||||
<Text fw={700} fz={15} c={INK}>
|
|
||||||
{paid
|
|
||||||
? "Final invoice paid"
|
|
||||||
: approved
|
|
||||||
? "Final invoice due"
|
|
||||||
: "Final invoice — your approval needed"}{" "}
|
|
||||||
— {invoice.invoiceNumber}
|
|
||||||
</Text>
|
|
||||||
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
|
|
||||||
{approved
|
|
||||||
? invoiceStatusLabel(invoice.status)
|
|
||||||
: "Awaiting your approval"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
<Text fz={20} fw={800} mt={6} c={INK}>
|
|
||||||
{invoice.totalAmount.toLocaleString()} {invoice.currency}
|
|
||||||
</Text>
|
|
||||||
{invoice.description ? (
|
|
||||||
<Text fz={13} c="dimmed" mt={2}>
|
|
||||||
{invoice.description}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
{!paid ? (
|
|
||||||
<Text fz={13} c="#9A6B1F" mt={6}>
|
|
||||||
{approved
|
|
||||||
? "Pay the amount above and attach your payment slip — Global Logistics will confirm the payment."
|
|
||||||
: "Review the invoice document from Global Logistics Djibouti and approve it to proceed with payment."}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Stack gap="xs" miw={260}>
|
|
||||||
{invoice.invoiceFile ? (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
onView({
|
|
||||||
name: invoice.invoiceFile!.name,
|
|
||||||
url: invoice.invoiceFile!.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View invoice
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{invoice.slipFile ? (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
onView({
|
|
||||||
name: invoice.slipFile!.name,
|
|
||||||
url: invoice.slipFile!.url,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View payment slip
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{!paid && !approved ? (
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
loading={approving}
|
|
||||||
leftSection={<Check size={15} />}
|
|
||||||
onClick={async () => {
|
|
||||||
setApproving(true);
|
|
||||||
try {
|
|
||||||
await contractsService.approveFinalInvoice(bookingId);
|
|
||||||
toast.success("Invoice approved — you can now pay");
|
|
||||||
onChanged();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : "Approval failed");
|
|
||||||
} finally {
|
|
||||||
setApproving(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Approve invoice
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{!paid && approved ? (
|
|
||||||
<>
|
|
||||||
<FileInput
|
|
||||||
placeholder={
|
|
||||||
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
|
|
||||||
}
|
|
||||||
value={slip}
|
|
||||||
onChange={setSlip}
|
|
||||||
size="sm"
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
size="sm"
|
|
||||||
loading={uploading}
|
|
||||||
disabled={!slip}
|
|
||||||
leftSection={<Upload size={15} />}
|
|
||||||
onClick={async () => {
|
|
||||||
if (!slip) return;
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
|
|
||||||
setSlip(null);
|
|
||||||
toast.success("Payment slip attached");
|
|
||||||
onChanged();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof Error ? e.message : "Upload failed",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
* Post-arrival additional duty/tax round (import): GL advises an extra amount
|
||||||
* with a notice; the customer pays offline and attaches another slip here.
|
* with a notice; the customer pays offline and attaches another slip here.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useClearanceFlow } from "./useClearanceFlow";
|
|||||||
interface BookingActionModalProps {
|
interface BookingActionModalProps {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
|
/** Open with one blank "Additional documents" row ready (answering a GL request). */
|
||||||
|
startWithAdHocRow?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,21 +31,30 @@ interface BookingActionModalProps {
|
|||||||
export function BookingActionModal({
|
export function BookingActionModal({
|
||||||
booking,
|
booking,
|
||||||
opened,
|
opened,
|
||||||
|
startWithAdHocRow,
|
||||||
onClose,
|
onClose,
|
||||||
}: BookingActionModalProps) {
|
}: BookingActionModalProps) {
|
||||||
if (!opened) return null;
|
if (!opened) return null;
|
||||||
return <BookingActionModalBody booking={booking} onClose={onClose} />;
|
return (
|
||||||
|
<BookingActionModalBody
|
||||||
|
booking={booking}
|
||||||
|
startWithAdHocRow={startWithAdHocRow}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BookingActionModalBody({
|
function BookingActionModalBody({
|
||||||
booking,
|
booking,
|
||||||
|
startWithAdHocRow,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
|
startWithAdHocRow?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
const flow = useClearanceFlow(booking);
|
const flow = useClearanceFlow(booking, { startWithAdHocRow });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const reference = booking.reference;
|
const reference = booking.reference;
|
||||||
|
|
||||||
|
|||||||
@@ -266,14 +266,14 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
one place. Newest first. */}
|
one place. Newest first. */}
|
||||||
{(clearance.docRequests?.length ?? 0) > 0 && (
|
{(clearance.docRequests?.length ?? 0) > 0 && (
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<Text fz="12.5px" fw={700} c="#10202F" mb={8}>
|
<Text fz="12.5px" fw={700} c="#C0392B" mb={8}>
|
||||||
Requested by Global Logistics
|
Requested by Global Logistics — please add these documents
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
{clearance.docRequests!.map((r) => (
|
{clearance.docRequests!.map((r) => (
|
||||||
<Alert
|
<Alert
|
||||||
key={r.id}
|
key={r.id}
|
||||||
color="blue"
|
color="red"
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
radius="md"
|
||||||
icon={<MessageSquare size={16} />}
|
icon={<MessageSquare size={16} />}
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
export type AdHocDoc = { name: string; file: File | null };
|
export type AdHocDoc = { name: string; file: File | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the customer's document name safe for a multipart field code (the API's
|
||||||
|
* `adHocLabel` turns it back into a label). Empty when unnamed, which keeps the
|
||||||
|
* old `custom_<n>` shape and lets the API fall back to the filename.
|
||||||
|
*/
|
||||||
|
function adHocSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulates everything the customer-facing clearance/operation flow needs:
|
* Encapsulates everything the customer-facing clearance/operation flow needs:
|
||||||
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
|
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
|
||||||
@@ -14,7 +28,10 @@ export type AdHocDoc = { name: string; file: File | null };
|
|||||||
* Both the booking detail clearance card and the home-page action modal drive
|
* Both the booking detail clearance card and the home-page action modal drive
|
||||||
* their UI off this single hook so the behaviour stays in lock-step.
|
* their UI off this single hook so the behaviour stays in lock-step.
|
||||||
*/
|
*/
|
||||||
export function useClearanceFlow(booking: Freight.IBooking) {
|
export function useClearanceFlow(
|
||||||
|
booking: Freight.IBooking,
|
||||||
|
opts: { startWithAdHocRow?: boolean } = {},
|
||||||
|
) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
|
|
||||||
@@ -25,7 +42,11 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
|
|
||||||
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
|
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
|
||||||
const [pending, setPending] = useState<Record<string, File>>({});
|
const [pending, setPending] = useState<Record<string, File>>({});
|
||||||
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
// Seeded with one blank row when the customer came here to answer a GL
|
||||||
|
// request, so the name + file inputs are already on screen.
|
||||||
|
const [adHoc, setAdHoc] = useState<AdHocDoc[]>(
|
||||||
|
opts.startWithAdHocRow ? [{ name: "", file: null }] : [],
|
||||||
|
);
|
||||||
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
|
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
|
||||||
const [scheduledDate, setScheduledDateState] = useState<string>("");
|
const [scheduledDate, setScheduledDateState] = useState<string>("");
|
||||||
// Export rail only: the specific train picked for that day.
|
// Export rail only: the specific train picked for that day.
|
||||||
@@ -192,7 +213,10 @@ export function useClearanceFlow(booking: Freight.IBooking) {
|
|||||||
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
|
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
|
||||||
const files: Record<string, File | null> = { ...pending };
|
const files: Record<string, File | null> = { ...pending };
|
||||||
adHoc.forEach((row, i) => {
|
adHoc.forEach((row, i) => {
|
||||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
// The document name the customer typed travels in the field code — it is
|
||||||
|
// the only channel a multipart part has — so GL sees "Special permit"
|
||||||
|
// rather than "scan_003.pdf". `adHocLabel` on the API decodes it back.
|
||||||
|
if (row.file) files[`custom_${adHocSlug(row.name)}_${Date.now()}${i}`] = row.file;
|
||||||
});
|
});
|
||||||
if (Object.keys(files).length === 0) return;
|
if (Object.keys(files).length === 0) return;
|
||||||
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
|
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
|
||||||
|
|||||||
@@ -81,31 +81,10 @@ export function useBookingPayables(booking: Freight.IBooking) {
|
|||||||
|
|
||||||
for (const inv of invoicesQ.data ?? []) {
|
for (const inv of invoicesQ.data ?? []) {
|
||||||
const balance = Number(inv.balanceAmount ?? 0);
|
const balance = Number(inv.balanceAmount ?? 0);
|
||||||
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) {
|
// The GL Djibouti post-offload final invoice was removed from the
|
||||||
// Raised as DRAFT; issuing IS the customer's approval, then slip-paid.
|
// clearance flow. Existing ones stay payable from the billing pages; they
|
||||||
if (inv.status === Freight.InvoiceStatus.Draft) {
|
// are no longer raised here or chased as an outstanding clearance item.
|
||||||
out.push({
|
if (inv.type === Freight.GL_FINAL_INVOICE_TYPE) continue;
|
||||||
id: inv.id,
|
|
||||||
label: "Final invoice",
|
|
||||||
detail: `${inv.invoiceNumber} · approve to proceed`,
|
|
||||||
amount: Number(inv.totalAmount),
|
|
||||||
currency: inv.currency,
|
|
||||||
action: "APPROVE",
|
|
||||||
anchor: PAYABLE_ANCHORS.customs,
|
|
||||||
});
|
|
||||||
} else if (isPayable(inv.status) && balance > 0) {
|
|
||||||
out.push({
|
|
||||||
id: inv.id,
|
|
||||||
label: "Final invoice",
|
|
||||||
detail: inv.invoiceNumber,
|
|
||||||
amount: balance,
|
|
||||||
currency: inv.currency,
|
|
||||||
action: "UPLOAD_SLIP",
|
|
||||||
anchor: PAYABLE_ANCHORS.customs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isPayable(inv.status) || balance <= 0) continue;
|
if (!isPayable(inv.status) || balance <= 0) continue;
|
||||||
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
|
if (inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE) {
|
||||||
out.push({
|
out.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user