mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
export flow and fix intercity issue
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop
|
||||||
|
* in their milestone list. The corridor budget builds its per-leg edges from
|
||||||
|
* route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP
|
||||||
|
* booking cannot resolve its own leg and conservatively occupies the WHOLE
|
||||||
|
* route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP)
|
||||||
|
* silently degrades to train-wide accounting.
|
||||||
|
*
|
||||||
|
* Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY
|
||||||
|
* route with a stop list that lacks it, shifting later stops down. Matched by
|
||||||
|
* yard CODE so the repair is portable across environments. Idempotent: routes
|
||||||
|
* already carrying Dire Dawa are untouched.
|
||||||
|
*/
|
||||||
|
export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface {
|
||||||
|
name = "BackfillDireDawaMilestone3080000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
dire uuid;
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO dire FROM freight.yards
|
||||||
|
WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL;
|
||||||
|
IF dire IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR r IN
|
||||||
|
SELECT rt.id
|
||||||
|
FROM freight.routes rt
|
||||||
|
JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH'
|
||||||
|
JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY'
|
||||||
|
WHERE rt.deleted_at IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM freight.route_milestones m
|
||||||
|
WHERE m.route_id = rt.id AND m.deleted_at IS NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m
|
||||||
|
WHERE m.route_id = rt.id AND m.yard_id = dire
|
||||||
|
AND m.deleted_at IS NULL)
|
||||||
|
LOOP
|
||||||
|
UPDATE freight.route_milestones
|
||||||
|
SET sequence_no = sequence_no + 1
|
||||||
|
WHERE route_id = r.id AND deleted_at IS NULL AND sequence_no >= 2;
|
||||||
|
INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no)
|
||||||
|
VALUES (r.id, dire, 2);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Data repair — not reversible.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1046,6 +1046,13 @@ export class BookingTransitionService {
|
|||||||
async exportTrainsForBooking(
|
async exportTrainsForBooking(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
|
overrides?: {
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
containerSizes?: string[];
|
||||||
|
cargoTypeId?: string;
|
||||||
|
cargoTypeCode?: string;
|
||||||
|
wagons?: number;
|
||||||
|
},
|
||||||
): Promise<ExportTrainOption[]> {
|
): Promise<ExportTrainOption[]> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
const date = new Date(scheduledDate);
|
const date = new Date(scheduledDate);
|
||||||
@@ -1064,6 +1071,7 @@ export class BookingTransitionService {
|
|||||||
return this.bookingBatchService.exportTrainOptionsForDay(
|
return this.bookingBatchService.exportTrainOptionsForDay(
|
||||||
scheduledBooking,
|
scheduledBooking,
|
||||||
eatDay(date),
|
eatDay(date),
|
||||||
|
overrides,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -765,8 +765,26 @@ export class BookingsController {
|
|||||||
async exportTrainsForBooking(
|
async exportTrainsForBooking(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Query("date") date: string,
|
@Query("date") date: string,
|
||||||
|
// Bare contract instances carry no cargo yet — the completion form sends
|
||||||
|
// what the customer is entering so per-type space reflects THEIR cargo.
|
||||||
|
@Query("containerTypeIds") containerTypeIds?: string,
|
||||||
|
@Query("containerSizes") containerSizes?: string,
|
||||||
|
@Query("cargoTypeId") cargoTypeId?: string,
|
||||||
|
@Query("cargoTypeCode") cargoTypeCode?: string,
|
||||||
|
@Query("wagons") wagons?: string,
|
||||||
) {
|
) {
|
||||||
return this.transitionService.exportTrainsForBooking(id, date);
|
const parsedWagons = Number(wagons);
|
||||||
|
return this.transitionService.exportTrainsForBooking(id, date, {
|
||||||
|
containerTypeIds: containerTypeIds
|
||||||
|
? containerTypeIds.split(",").filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
containerSizes: containerSizes
|
||||||
|
? containerSizes.split(",").filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
cargoTypeId: cargoTypeId || undefined,
|
||||||
|
cargoTypeCode: cargoTypeCode || undefined,
|
||||||
|
wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/operation/review")
|
@Post(":id/operation/review")
|
||||||
|
|||||||
@@ -866,6 +866,7 @@ export class ContractBookingService {
|
|||||||
const completed = await this.bookingTransitionService.requestOperation(
|
const completed = await this.bookingTransitionService.requestOperation(
|
||||||
booking.id,
|
booking.id,
|
||||||
dto.scheduledDate,
|
dto.scheduledDate,
|
||||||
|
dto.trainScheduleId ?? null,
|
||||||
);
|
);
|
||||||
return { booking: completed, warnings };
|
return { booking: completed, warnings };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,16 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'EXPORT rail only: the specific train (schedule id) picked from ' +
|
||||||
|
'GET /bookings/:id/export-trains for the shipment day. The reserve path ' +
|
||||||
|
'locks onto this train; 409 when it no longer fits. Ignored otherwise.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
trainScheduleId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
|
|||||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
@@ -459,6 +461,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
group.destinationYardId,
|
group.destinationYardId,
|
||||||
group.day,
|
group.day,
|
||||||
);
|
);
|
||||||
|
// Backstop: PAID bookings stranded without a schedule (hold expired before
|
||||||
|
// the payment landed) get re-placed onto whatever fits today.
|
||||||
|
await this.rescueStrandedPaidForDay(group.day);
|
||||||
for (const scheduleId of scheduleIds) {
|
for (const scheduleId of scheduleIds) {
|
||||||
await this.settleDueReservations(scheduleId);
|
await this.settleDueReservations(scheduleId);
|
||||||
await this.reconcilePaidUnlinked(scheduleId);
|
await this.reconcilePaidUnlinked(scheduleId);
|
||||||
@@ -519,17 +524,26 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
if (!booking) return;
|
if (!booking) return;
|
||||||
if (!booking.trainScheduleId) {
|
if (!booking.trainScheduleId) {
|
||||||
// A paid booking with no train is money taken and nothing boarding —
|
// A paid booking with no train is money taken and nothing boarding. The
|
||||||
// scream so staff pin it to a schedule manually (batch board / assign).
|
// hold was expired before the payment landed (webhook lag beat the
|
||||||
|
// reconcile, or the stranding predates it) — try to re-place it on a
|
||||||
|
// fitting same-day train before falling back to a manual-assign scream.
|
||||||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||||
this.logger.error(
|
const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking);
|
||||||
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
if (!rescuedScheduleId) {
|
||||||
`its reservation was likely expired before the payment landed. ` +
|
this.logger.error(
|
||||||
`Assign it to a schedule manually from the batch board.`,
|
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
||||||
);
|
`its reservation was likely expired before the payment landed and no ` +
|
||||||
|
`same-day train fits it. Assign it to a schedule manually from the batch board.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
booking.trainScheduleId = rescuedScheduleId;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS
|
||||||
|
|
||||||
const isBatchPaid =
|
const isBatchPaid =
|
||||||
booking.status === "SELECTED_FOR_BATCH" ||
|
booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
@@ -644,6 +658,69 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.ensurePaidBookingAllocated(bookingId);
|
await this.ensurePaidBookingAllocated(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
|
||||||
|
* keyed on train_schedule_id, so a booking whose hold was expired (schedule
|
||||||
|
* cleared) before its payment landed never re-enters it. Sweep the day's
|
||||||
|
* PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which
|
||||||
|
* re-places them on a fitting train.
|
||||||
|
*/
|
||||||
|
private async rescueStrandedPaidForDay(day: string): Promise<void> {
|
||||||
|
const stranded: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.bookings
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND train_schedule_id IS NULL
|
||||||
|
AND (payment_status = 'PAID' OR status = 'PAID')
|
||||||
|
AND scheduled_date IS NOT NULL
|
||||||
|
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
|
||||||
|
[day],
|
||||||
|
);
|
||||||
|
for (const { id } of stranded) {
|
||||||
|
await this.ensurePaidBookingAllocated(id).catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
`Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-place a PAID booking whose hold was expired before the payment landed
|
||||||
|
* (trainScheduleId already cleared). Picks the earliest same-day train that
|
||||||
|
* still fits the booking's whole need on ITS OWN leg and pins the booking to
|
||||||
|
* it. Returns the schedule id, or null when no train fits (manual assign).
|
||||||
|
*/
|
||||||
|
private async replaceStrandedPaidBooking(
|
||||||
|
booking: Booking,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!booking.scheduledDate) return null;
|
||||||
|
// The booking loaded by ensurePaidBookingAllocated carries no cargo
|
||||||
|
// relations; needFor/fittingTrainsForDay derive the wagon need from them.
|
||||||
|
const full = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: booking.id },
|
||||||
|
relations: {
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
cargoType: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!full) return null;
|
||||||
|
const day = eatDay(new Date(booking.scheduledDate));
|
||||||
|
const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
|
||||||
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
const need = this.needFor(full, wagonDims);
|
||||||
|
const fitting = await this.fittingTrainsForDay(full, day, direction);
|
||||||
|
const target = fitting.find((t) => t.freeWagons >= need.wagons);
|
||||||
|
if (!target) return null;
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(booking.id, { trainScheduleId: target.scheduleId });
|
||||||
|
this.logger.warn(
|
||||||
|
`[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` +
|
||||||
|
`onto schedule ${target.scheduleId} — its hold expired before the payment landed`,
|
||||||
|
);
|
||||||
|
return target.scheduleId;
|
||||||
|
}
|
||||||
|
|
||||||
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
||||||
async getOpenOfferSummary(bookingId: string): Promise<{
|
async getOpenOfferSummary(bookingId: string): Promise<{
|
||||||
offeredWagons: number;
|
offeredWagons: number;
|
||||||
@@ -911,7 +988,50 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
async exportTrainOptionsForDay(
|
async exportTrainOptionsForDay(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
day: string,
|
day: string,
|
||||||
|
overrides?: {
|
||||||
|
/** Cargo the customer is entering on a form (bare contract instance —
|
||||||
|
* nothing persisted yet): container types drive the per-type space. */
|
||||||
|
containerTypeIds?: string[];
|
||||||
|
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||||||
|
containerSizes?: string[];
|
||||||
|
/** Bulk counterparts of the container inputs. */
|
||||||
|
cargoTypeId?: string;
|
||||||
|
cargoTypeCode?: string;
|
||||||
|
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||||||
|
wagons?: number;
|
||||||
|
},
|
||||||
): Promise<ExportTrainOption[]> {
|
): Promise<ExportTrainOption[]> {
|
||||||
|
const sizeFts = (overrides?.containerSizes ?? [])
|
||||||
|
.map((s) => parseInt(s, 10))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
if (overrides?.containerTypeIds?.length || sizeFts.length) {
|
||||||
|
const types = await this.dataSource.getRepository(ContainerType).find({
|
||||||
|
where: overrides?.containerTypeIds?.length
|
||||||
|
? { id: In(overrides.containerTypeIds) }
|
||||||
|
: { sizeFt: In(sizeFts) },
|
||||||
|
relations: { wagonTypes: true },
|
||||||
|
});
|
||||||
|
booking = {
|
||||||
|
...booking,
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
bookingContainers: types.map((ct) => ({ containerType: ct })),
|
||||||
|
} as Booking;
|
||||||
|
} else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) {
|
||||||
|
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||||
|
where: overrides.cargoTypeId
|
||||||
|
? { id: overrides.cargoTypeId }
|
||||||
|
: { code: overrides.cargoTypeCode },
|
||||||
|
relations: { wagonTypes: true },
|
||||||
|
});
|
||||||
|
booking = {
|
||||||
|
...booking,
|
||||||
|
freightType: "BULK",
|
||||||
|
cargoType: cargoType ?? undefined,
|
||||||
|
} as Booking;
|
||||||
|
}
|
||||||
|
if (overrides?.wagons && overrides.wagons > 0) {
|
||||||
|
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
||||||
|
}
|
||||||
const corridor = await this.trainSchedulesRepository.findAll({
|
const corridor = await this.trainSchedulesRepository.findAll({
|
||||||
where: [
|
where: [
|
||||||
{ status: TrainScheduleStatusEnum.Draft },
|
{ status: TrainScheduleStatusEnum.Draft },
|
||||||
@@ -2200,14 +2320,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
||||||
* shared wagon) and government bookings never split (they preempt).
|
* shared wagon) and government bookings never split (they preempt).
|
||||||
*
|
*
|
||||||
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
* IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is
|
||||||
* enabled: export historically rides one train whole, so splitting it changes
|
* eligible only when export split is enabled: export historically rides one
|
||||||
* the FCFS money path — each split part still rides ONE train whole, and the
|
* train whole, so splitting it changes the FCFS money path — each split part
|
||||||
* leftover becomes its own booking on the next train.
|
* still rides ONE train whole, and the leftover becomes its own booking on
|
||||||
|
* the next train.
|
||||||
*/
|
*/
|
||||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||||
const directionOk =
|
const directionOk =
|
||||||
booking.tradeDirection === "IMPORT" ||
|
booking.tradeDirection === "IMPORT" ||
|
||||||
|
booking.tradeDirection === "DOMESTIC" ||
|
||||||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||||||
return (
|
return (
|
||||||
!isPair &&
|
!isPair &&
|
||||||
@@ -2818,6 +2940,41 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercity booking that does not fit its leg whole: offer the largest part
|
||||||
|
* that does (split-on-payment, customer notified with a pay window), sized
|
||||||
|
* against the leg's remaining room AND the train's physical wagon stock.
|
||||||
|
* Returns true when an offer was opened. The caller's budget is mutated so
|
||||||
|
* later bookings in the same accept pass see the offer's consumption.
|
||||||
|
*/
|
||||||
|
async offerIntercityPartial(
|
||||||
|
booking: Booking,
|
||||||
|
scheduleId: string,
|
||||||
|
budget: CorridorBudget,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
const need = this.needFor(booking, wagonDims);
|
||||||
|
const allowed = await this.loadAllowedWagonTypeIds();
|
||||||
|
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed);
|
||||||
|
const schedule =
|
||||||
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) return false;
|
||||||
|
const stock = await this.stockLedgerFor(schedule, budget);
|
||||||
|
const cand = { id: scheduleId, budget, armed: false, stock };
|
||||||
|
const offered = await this.maybeOfferPartial(
|
||||||
|
booking,
|
||||||
|
false,
|
||||||
|
[cand],
|
||||||
|
need,
|
||||||
|
wagonTypeIds,
|
||||||
|
);
|
||||||
|
if (offered && cand.armed) {
|
||||||
|
this.armSettle(scheduleId);
|
||||||
|
this.notifyBoardChanged(scheduleId, 'intercity_partial_offered');
|
||||||
|
}
|
||||||
|
return offered;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- mutations ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -217,10 +217,20 @@ export class IntercityService {
|
|||||||
// board a train that is full only on other legs.
|
// board a train that is full only on other legs.
|
||||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
if (!budget.fits(need, leg)) {
|
if (!budget.fits(need, leg)) {
|
||||||
|
// Offer the part that DOES fit the leg (split-on-payment): customer is
|
||||||
|
// notified with a pay window for the fitting wagons; the remainder can
|
||||||
|
// be re-booked on a later train. Budget is consumed by the offer so the
|
||||||
|
// next booking in this pass sees the reduced room.
|
||||||
|
const offered = await this.bookingBatchService.offerIntercityPartial(
|
||||||
|
booking,
|
||||||
|
scheduleId,
|
||||||
|
budget,
|
||||||
|
);
|
||||||
rejected.push({
|
rejected.push({
|
||||||
bookingId,
|
bookingId,
|
||||||
reason:
|
reason: offered
|
||||||
'Does not fit the remaining wagon/weight/length capacity for this train',
|
? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer'
|
||||||
|
: 'Does not fit the remaining wagon/weight/length capacity for this train',
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { OperationDatePicker } from "@edr/ui-common";
|
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
@@ -268,6 +268,8 @@ export default function GlCreateBookingForm() {
|
|||||||
}, [bookingWindows]);
|
}, [bookingWindows]);
|
||||||
|
|
||||||
const [scheduledDate, setScheduledDate] = useState("");
|
const [scheduledDate, setScheduledDate] = useState("");
|
||||||
|
// EXPORT rail completion: the specific train GL picks for the shipment day.
|
||||||
|
const [trainScheduleId, setTrainScheduleId] = useState("");
|
||||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
// The customer states the billing currency on their shipment request — GL
|
// The customer states the billing currency on their shipment request — GL
|
||||||
@@ -578,6 +580,45 @@ export default function GlCreateBookingForm() {
|
|||||||
enabled: cargoQuery !== null && !isIntercity,
|
enabled: cargoQuery !== null && !isIntercity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// EXPORT completes pick the TRAIN, not just the day (portal parity). Only
|
||||||
|
// when completing an initiated instance — a fresh GL create goes through
|
||||||
|
// clearance and picks its train there.
|
||||||
|
const isExportPick =
|
||||||
|
contract?.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||||
|
const wagonsEstimate = useMemo(() => {
|
||||||
|
if (!isContainer) return undefined;
|
||||||
|
const ft20 = containerLines
|
||||||
|
.filter((l) => parseInt(l.containerSize, 10) === 20)
|
||||||
|
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||||
|
const ft40 = containerLines
|
||||||
|
.filter((l) => parseInt(l.containerSize, 10) === 40)
|
||||||
|
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||||
|
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||||
|
return wagons > 0 ? wagons : undefined;
|
||||||
|
}, [isContainer, containerLines]);
|
||||||
|
const exportTrainsQuery = useQuery({
|
||||||
|
...api.trainScheduling.exportTrains.queryOptions({
|
||||||
|
input: {
|
||||||
|
bookingId: completeBookingId ?? "",
|
||||||
|
date: scheduledDate,
|
||||||
|
cargo: {
|
||||||
|
containerSizes: isContainer
|
||||||
|
? containerLines
|
||||||
|
.filter((l) => Number(l.quantity || 0) >= 1)
|
||||||
|
.map((l) => l.containerSize)
|
||||||
|
: undefined,
|
||||||
|
cargoTypeCode: !isContainer
|
||||||
|
? (contract?.pricingBreakdown?.lineItems?.find(
|
||||||
|
(li) => li.cargoTypeCode,
|
||||||
|
)?.cargoTypeCode ?? undefined)
|
||||||
|
: undefined,
|
||||||
|
wagons: wagonsEstimate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: isExportPick && Boolean(scheduledDate),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Line handling totals are a roll-up of the per-container switches — the
|
* Line handling totals are a roll-up of the per-container switches — the
|
||||||
* count is however many containers ticked each service. Recomputed on every
|
* count is however many containers ticked each service. Recomputed on every
|
||||||
@@ -846,6 +887,8 @@ export default function GlCreateBookingForm() {
|
|||||||
...(scheduledDate
|
...(scheduledDate
|
||||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
|
// EXPORT rail: lock the booking onto the picked train.
|
||||||
|
...(trainScheduleId ? { trainScheduleId } : {}),
|
||||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
// Equipment return: WITH_RETURN contracts derive it server-side from the
|
// Equipment return: WITH_RETURN contracts derive it server-side from the
|
||||||
// per-line return quantities; only legacy contracts (no value chosen at
|
// per-line return quantities; only legacy contracts (no value chosen at
|
||||||
@@ -1667,7 +1710,11 @@ export default function GlCreateBookingForm() {
|
|||||||
availableDays={availableDays ?? []}
|
availableDays={availableDays ?? []}
|
||||||
isLoading={daysLoading}
|
isLoading={daysLoading}
|
||||||
value={scheduledDate}
|
value={scheduledDate}
|
||||||
onChange={setScheduledDate}
|
onChange={(d) => {
|
||||||
|
setScheduledDate(d);
|
||||||
|
// A new day invalidates the old train pick.
|
||||||
|
setTrainScheduleId("");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{showErrors && dateError && (
|
{showErrors && dateError && (
|
||||||
@@ -1675,6 +1722,14 @@ export default function GlCreateBookingForm() {
|
|||||||
{dateError}
|
{dateError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{isExportPick && scheduledDate ? (
|
||||||
|
<ExportTrainPicker
|
||||||
|
options={exportTrainsQuery.data ?? []}
|
||||||
|
loading={exportTrainsQuery.isLoading}
|
||||||
|
value={trainScheduleId}
|
||||||
|
onChange={setTrainScheduleId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</StepCard>
|
</StepCard>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PaginatedResponse } from "@edr/types";
|
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||||
@@ -438,6 +438,31 @@ export const api = {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
exportTrains: endpoint<
|
||||||
|
{
|
||||||
|
bookingId: string;
|
||||||
|
date: string;
|
||||||
|
cargo?: {
|
||||||
|
containerSizes?: string[];
|
||||||
|
cargoTypeCode?: string;
|
||||||
|
wagons?: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Freight.ExportTrainOption[]
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"export-trains",
|
||||||
|
({ bookingId, date, cargo }) =>
|
||||||
|
trainSchedulingService.getExportTrains(bookingId, date, cargo),
|
||||||
|
({ bookingId, date, cargo }) => [
|
||||||
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
|
"export-trains",
|
||||||
|
bookingId,
|
||||||
|
date,
|
||||||
|
JSON.stringify(cargo ?? {}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
|
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"track",
|
"track",
|
||||||
|
|||||||
@@ -189,6 +189,29 @@ export const trainSchedulingService = {
|
|||||||
|
|
||||||
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
|
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
|
||||||
// is serialized as a JSON string param (the server parses it).
|
// is serialized as a JSON string param (the server parses it).
|
||||||
|
// Export train picker for a booking's shipment day; cargo params cover bare
|
||||||
|
// instances whose cargo only exists on the form so far.
|
||||||
|
getExportTrains: async (
|
||||||
|
bookingId: string,
|
||||||
|
date: string,
|
||||||
|
cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number },
|
||||||
|
): Promise<Freight.ExportTrainOption[]> => {
|
||||||
|
const response = await client.get<Freight.ExportTrainOption[]>(
|
||||||
|
`/bookings/${bookingId}/export-trains`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
date,
|
||||||
|
...(cargo?.containerSizes?.length
|
||||||
|
? { containerSizes: cargo.containerSizes.join(",") }
|
||||||
|
: {}),
|
||||||
|
...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}),
|
||||||
|
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
getAvailableDaysForCargo: async (
|
getAvailableDaysForCargo: async (
|
||||||
query: Freight.AvailableDaysForCargoQuery,
|
query: Freight.AvailableDaysForCargoQuery,
|
||||||
): Promise<string[]> => {
|
): Promise<string[]> => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { ExportTrainPicker, isViewable } from "@edr/ui-common";
|
||||||
|
|
||||||
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
import { IconSquare } from "../BookingDetailPage/components/Documents";
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,6 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
|||||||
import { bookingDocNoun } from "./bookingNextAction";
|
import { bookingDocNoun } from "./bookingNextAction";
|
||||||
import { OperationDatePicker } from "./OperationDatePicker";
|
import { OperationDatePicker } from "./OperationDatePicker";
|
||||||
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
||||||
import { ExportTrainPicker } from "./ExportTrainPicker";
|
|
||||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||||
|
|
||||||
const BORDER = "#E6ECF2";
|
const BORDER = "#E6ECF2";
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { OperationDatePicker } from "@edr/ui-common";
|
import { ExportTrainPicker, OperationDatePicker } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
contractsService,
|
contractsService,
|
||||||
@@ -342,6 +342,10 @@ function NewShipmentBookingForm({
|
|||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
|
// EXPORT rail: lock the booking onto the train the customer picked.
|
||||||
|
...(values.trainScheduleId
|
||||||
|
? { trainScheduleId: values.trainScheduleId }
|
||||||
|
: {}),
|
||||||
...(legacyReturnToggle
|
...(legacyReturnToggle
|
||||||
? {
|
? {
|
||||||
equipmentReturn: values.withReturn
|
equipmentReturn: values.withReturn
|
||||||
@@ -505,7 +509,12 @@ function NewShipmentBookingForm({
|
|||||||
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
|
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
|
||||||
{contract.freightType === "CONTAINER" &&
|
{contract.freightType === "CONTAINER" &&
|
||||||
!contract.equipmentReturn && <EquipmentReturnStep form={form} />}
|
!contract.equipmentReturn && <EquipmentReturnStep form={form} />}
|
||||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
<ScheduleStep
|
||||||
|
form={form}
|
||||||
|
contract={contract}
|
||||||
|
routes={routes}
|
||||||
|
completeBookingId={completeBookingId ?? null}
|
||||||
|
/>
|
||||||
<NotesSection form={form} />
|
<NotesSection form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -934,10 +943,13 @@ function ScheduleStep({
|
|||||||
form,
|
form,
|
||||||
contract,
|
contract,
|
||||||
routes,
|
routes,
|
||||||
|
completeBookingId,
|
||||||
}: {
|
}: {
|
||||||
form: ShipmentForm;
|
form: ShipmentForm;
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
routes: Freight.IContractRoute[];
|
routes: Freight.IContractRoute[];
|
||||||
|
/** Set when completing an initiated instance — enables the train picker. */
|
||||||
|
completeBookingId: string | null;
|
||||||
}) {
|
}) {
|
||||||
const contractRouteId = form.watch("contractRouteId");
|
const contractRouteId = form.watch("contractRouteId");
|
||||||
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
|
||||||
@@ -996,6 +1008,50 @@ function ScheduleStep({
|
|||||||
enabled: cargoQuery !== null && !isIntercity,
|
enabled: cargoQuery !== null && !isIntercity,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export completion picks the TRAIN, not just the day (mirrors the
|
||||||
|
// clearance-flow picker). Only when an initiated instance exists — a plain
|
||||||
|
// drawdown create goes through clearance and picks its train there.
|
||||||
|
const scheduledDate = form.watch("scheduledDate");
|
||||||
|
const selectedTrainId = form.watch("trainScheduleId");
|
||||||
|
const isExportPick =
|
||||||
|
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||||
|
const wagonsEstimate = useMemo(() => {
|
||||||
|
if (contract.freightType !== "CONTAINER") return undefined;
|
||||||
|
const lines = containerLines ?? [];
|
||||||
|
const ft20 = lines
|
||||||
|
.filter((l) => l.containerSize === "20ft")
|
||||||
|
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||||
|
const ft40 = lines
|
||||||
|
.filter((l) => l.containerSize === "40ft")
|
||||||
|
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||||
|
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||||
|
return wagons > 0 ? wagons : undefined;
|
||||||
|
}, [contract.freightType, containerLines]);
|
||||||
|
const exportTrainsQuery = useQuery({
|
||||||
|
...api.bookings.getExportTrains.queryOptions({
|
||||||
|
input: {
|
||||||
|
bookingId: completeBookingId ?? "",
|
||||||
|
date: scheduledDate ?? "",
|
||||||
|
cargo: {
|
||||||
|
containerSizes:
|
||||||
|
contract.freightType === "CONTAINER"
|
||||||
|
? (containerLines ?? [])
|
||||||
|
.filter((l) => Number(l.quantity || 0) >= 1)
|
||||||
|
.map((l) => l.containerSize)
|
||||||
|
: undefined,
|
||||||
|
cargoTypeCode:
|
||||||
|
contract.freightType === "BULK"
|
||||||
|
? (contract.pricingBreakdown?.lineItems?.find(
|
||||||
|
(li) => li.cargoTypeCode,
|
||||||
|
)?.cargoTypeCode ?? undefined)
|
||||||
|
: undefined,
|
||||||
|
wagons: wagonsEstimate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: isExportPick && Boolean(scheduledDate),
|
||||||
|
});
|
||||||
|
|
||||||
if (isIntercity) {
|
if (isIntercity) {
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
@@ -1067,7 +1123,11 @@ function ScheduleStep({
|
|||||||
availableDays={availableDays ?? []}
|
availableDays={availableDays ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
onChange={(d) => field.onChange(d)}
|
onChange={(d) => {
|
||||||
|
field.onChange(d);
|
||||||
|
// A new day invalidates the old train pick.
|
||||||
|
form.setValue("trainScheduleId", "");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{fieldState.error?.message && (
|
{fieldState.error?.message && (
|
||||||
@@ -1075,6 +1135,14 @@ function ScheduleStep({
|
|||||||
{fieldState.error.message}
|
{fieldState.error.message}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{isExportPick && scheduledDate ? (
|
||||||
|
<ExportTrainPicker
|
||||||
|
options={exportTrainsQuery.data ?? []}
|
||||||
|
loading={exportTrainsQuery.isLoading}
|
||||||
|
value={selectedTrainId ?? ""}
|
||||||
|
onChange={(id) => form.setValue("trainScheduleId", id)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ const containerLineSchema = z.object({
|
|||||||
const shipmentFormBase = z.object({
|
const shipmentFormBase = z.object({
|
||||||
contractRouteId: z.string().default(""),
|
contractRouteId: z.string().default(""),
|
||||||
scheduledDate: z.string().default(""),
|
scheduledDate: z.string().default(""),
|
||||||
|
// EXPORT rail: the specific train picked for the shipment day (schedule id).
|
||||||
|
trainScheduleId: z.string().default(""),
|
||||||
// The contract quotes in USD; the customer picks the billing currency for
|
// The contract quotes in USD; the customer picks the billing currency for
|
||||||
// THIS shipment. Intercity is forced to ETB (server-enforced too).
|
// THIS shipment. Intercity is forced to ETB (server-enforced too).
|
||||||
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
|
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
|
||||||
|
|||||||
@@ -469,10 +469,18 @@ export const api = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
getExportTrains: endpoint<
|
getExportTrains: endpoint<
|
||||||
{ bookingId: string; date: string },
|
{
|
||||||
|
bookingId: string;
|
||||||
|
date: string;
|
||||||
|
cargo?: {
|
||||||
|
containerSizes?: string[];
|
||||||
|
cargoTypeCode?: string;
|
||||||
|
wagons?: number;
|
||||||
|
};
|
||||||
|
},
|
||||||
Freight.ExportTrainOption[]
|
Freight.ExportTrainOption[]
|
||||||
>("train-scheduling", "exportTrains", ({ bookingId, date }) =>
|
>("train-scheduling", "exportTrains", ({ bookingId, date, cargo }) =>
|
||||||
bookingsService.getExportTrains(bookingId, date),
|
bookingsService.getExportTrains(bookingId, date, cargo),
|
||||||
),
|
),
|
||||||
|
|
||||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
|
|||||||
@@ -510,13 +510,25 @@ export const bookingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Export train picker: the day's export trains with per-wagon-type free space.
|
// Export train picker: the day's export trains with per-wagon-type free space.
|
||||||
|
// The cargo params cover bare contract instances (nothing persisted yet) —
|
||||||
|
// sizes/code/wagons come from what the customer is entering on the form.
|
||||||
getExportTrains: async (
|
getExportTrains: async (
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
date: string,
|
date: string,
|
||||||
|
cargo?: { containerSizes?: string[]; cargoTypeCode?: string; wagons?: number },
|
||||||
): Promise<Freight.ExportTrainOption[]> => {
|
): Promise<Freight.ExportTrainOption[]> => {
|
||||||
const { data } = await client.get(
|
const { data } = await client.get(
|
||||||
`/api/bookings/${bookingId}/export-trains`,
|
`/api/bookings/${bookingId}/export-trains`,
|
||||||
{ params: { date } },
|
{
|
||||||
|
params: {
|
||||||
|
date,
|
||||||
|
...(cargo?.containerSizes?.length
|
||||||
|
? { containerSizes: cargo.containerSizes.join(",") }
|
||||||
|
: {}),
|
||||||
|
...(cargo?.cargoTypeCode ? { cargoTypeCode: cargo.cargoTypeCode } : {}),
|
||||||
|
...(cargo?.wagons ? { wagons: cargo.wagons } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return data.data as Freight.ExportTrainOption[];
|
return data.data as Freight.ExportTrainOption[];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -999,6 +999,8 @@ export interface CreateBookingUnderContractDto {
|
|||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
/** EXPORT rail only: the train (schedule id) picked from GET /bookings/:id/export-trains. */
|
||||||
|
trainScheduleId?: string;
|
||||||
/** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */
|
/** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */
|
||||||
equipmentReturn?: string;
|
equipmentReturn?: string;
|
||||||
containers?: CreateBookingContainerLineDto[];
|
containers?: CreateBookingContainerLineDto[];
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { ExportTrainPicker } from "./ExportTrainPicker";
|
||||||
|
export type { ExportTrainPickerProps } from "./ExportTrainPicker";
|
||||||
@@ -24,6 +24,8 @@ export { useFileViewer } from "./hooks/useFileViewer";
|
|||||||
|
|
||||||
export { OperationDatePicker } from "./components/OperationDatePicker";
|
export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||||
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||||
|
export { ExportTrainPicker } from "./components/ExportTrainPicker";
|
||||||
|
export type { ExportTrainPickerProps } from "./components/ExportTrainPicker";
|
||||||
|
|
||||||
export { CountdownTimer } from "./components/CountdownTimer";
|
export { CountdownTimer } from "./components/CountdownTimer";
|
||||||
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||||
|
|||||||
Reference in New Issue
Block a user