mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
shipping line
This commit is contained in:
@@ -26,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { isRoadService } from '../bookings/road.util';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
@@ -154,6 +155,19 @@ export interface ExportTrainOption {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
|
||||
export interface TrainOptionCargoOverrides {
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||
export interface AllocationCandidate {
|
||||
id: string;
|
||||
@@ -1051,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async exportTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
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;
|
||||
},
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return this.buildTrainOptions(booking, candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same per-train wagon-availability cards, but for the trains DEDICATED
|
||||
* to a shipping line on the booking's lane + day. Same option shape as the
|
||||
* export picker so the portal reuses the same component; `isOpen`
|
||||
* additionally respects the dedicated close offset (windowClosesAt), since
|
||||
* these trains run no window cycle.
|
||||
*/
|
||||
async dedicatedTrainOptionsForDay(
|
||||
booking: Booking,
|
||||
day: string | null,
|
||||
shippingLineCompanyId: string,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<ExportTrainOption[]> {
|
||||
booking = await this.withCargoOverrides(booking, overrides);
|
||||
const dedicated = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
|
||||
],
|
||||
});
|
||||
const candidates = dedicated
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
// A day narrows to that departure day; without one, every upcoming
|
||||
// departure on the lane is listed (the picker's full card list).
|
||||
(day
|
||||
? eatDay(s.scheduledDepartureDate) === day
|
||||
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
|
||||
(!booking.originYardId || s.originStationId === booking.originYardId) &&
|
||||
(!booking.destinationYardId ||
|
||||
s.destinationStationId === booking.destinationYardId),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
const options = await this.buildTrainOptions(booking, candidates);
|
||||
const now = Date.now();
|
||||
return options.map((o) => ({
|
||||
...o,
|
||||
isOpen:
|
||||
o.isOpen &&
|
||||
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
|
||||
private async withCargoOverrides(
|
||||
booking: Booking,
|
||||
overrides?: TrainOptionCargoOverrides,
|
||||
): Promise<Booking> {
|
||||
const sizeFts = (overrides?.containerSizes ?? [])
|
||||
.map((s) => parseInt(s, 10))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
@@ -1095,26 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (overrides?.wagons && overrides.wagons > 0) {
|
||||
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
||||
}
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
// Dedicated shipping-line trains are never customer-booking targets.
|
||||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.direction === 'EXPORT',
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** One availability card per candidate schedule — the export picker's math. */
|
||||
private async buildTrainOptions(
|
||||
booking: Booking,
|
||||
candidates: TrainSchedule[],
|
||||
): Promise<ExportTrainOption[]> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
|
||||
const neededWagons = this.wagonsFor(booking, wagonDims);
|
||||
@@ -3185,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
|
||||
* dedicated train for the booking's lane and shipment day.
|
||||
*
|
||||
* Runs at operation-accept: shipping lines pay later on the credit ledger,
|
||||
* so there is no pay window between accept and wagon placement — the
|
||||
* booking boards its train immediately. Customer bookings never come here;
|
||||
* they keep the batch pool → reserve → pay → allocate pipeline.
|
||||
*
|
||||
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
|
||||
* (without the PAID stamps the customer hold writes — nothing was paid).
|
||||
* No dedicated train on the day is not an error: the booking simply stays
|
||||
* in the ordinary day pool for the batch engine.
|
||||
*/
|
||||
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: bookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
|
||||
if (isRoadService(booking.serviceType)) return;
|
||||
|
||||
const day = eatDay(booking.scheduledDate);
|
||||
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: [
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const target = dedicated.find(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
|
||||
);
|
||||
if (!target) {
|
||||
this.logger.log(
|
||||
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
|
||||
`train on ${day} — left in the day pool for the batch engine`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Point the booking at its train BEFORE the shortage probe — the probe
|
||||
// reads the link to size the need against that schedule's wagons.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
trainScheduleId: target.id,
|
||||
} as never);
|
||||
booking.trainScheduleId = target.id;
|
||||
|
||||
// One dedicated train carries ONE booking: the accept claims the train by
|
||||
// closing its booking window on the spot. Both gates a later booking
|
||||
// passes — the day picker (isStillOpen on windowClosesAt) and the
|
||||
// completion's dedicated-day check — read these fields, so a second
|
||||
// booking can never pick this train.
|
||||
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
|
||||
bookingWindowStatus: "CLOSED",
|
||||
windowClosesAt: new Date(),
|
||||
} as never);
|
||||
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
|
||||
|
||||
const shortage =
|
||||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||||
target.id,
|
||||
booking.id,
|
||||
);
|
||||
if (shortage) {
|
||||
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
|
||||
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
|
||||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||||
schedulingStatus: "WAITING_FOR_WAGON",
|
||||
} as never);
|
||||
this.logger.warn(
|
||||
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
|
||||
`dedicated train ${target.reference ?? target.id}: needs ` +
|
||||
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
|
||||
);
|
||||
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.allocate(target.id, booking, "shipping_line");
|
||||
}
|
||||
|
||||
/**
|
||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||
@@ -3544,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async allocate(
|
||||
scheduleId: string,
|
||||
booking: Booking,
|
||||
reason: "paid" | "gov",
|
||||
reason: "paid" | "gov" | "shipping_line",
|
||||
): Promise<void> {
|
||||
// Stamp the computed wagon need on the link. Several callers pass a booking
|
||||
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@@ -66,10 +67,16 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
this.logger.log(`${logLabel} — ${this.ref(b)}`);
|
||||
// One resolver for both channels — the company row's own email column is
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr).
|
||||
const { phone, email } = b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
// only set for a Fayda-verified owner (see companyNotifyEmailExpr). A
|
||||
// shipping-line booking has no company; its contact is the line's row.
|
||||
const { phone, email } = b.shippingLineCompanyId
|
||||
? await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId,
|
||||
)
|
||||
: b.companyId
|
||||
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
|
||||
: { phone: null, email: null };
|
||||
|
||||
if (phone) {
|
||||
try {
|
||||
@@ -90,13 +97,42 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist + push an in-app item to all portal users of the booking's company. */
|
||||
/**
|
||||
* Persist + push an in-app item to the booking's portal owner: every portal
|
||||
* user of the company, or — for a shipping-line booking — the line's own
|
||||
* account, deep-linked into the shipping-line app (/shipping-line/*).
|
||||
*/
|
||||
private inApp(
|
||||
b: Booking,
|
||||
title: string,
|
||||
body: string,
|
||||
overrides: Partial<NotifyInput> = {},
|
||||
): void {
|
||||
if (b.shippingLineCompanyId) {
|
||||
void (async () => {
|
||||
const { userId } = await resolveShippingLineNotifyTarget(
|
||||
this.dataSource,
|
||||
b.shippingLineCompanyId!,
|
||||
);
|
||||
if (!userId) return;
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [userId] },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.SCHEDULE_UPDATE,
|
||||
title,
|
||||
body,
|
||||
data: { bookingId: b.id, reference: b.reference },
|
||||
...overrides,
|
||||
// After the spread: the bell must land the line on ITS booking page.
|
||||
link: `/shipping-line/bookings/${b.id}`,
|
||||
});
|
||||
})().catch((err) =>
|
||||
this.logger.warn(
|
||||
`shipping-line inApp failed for ${this.ref(b)}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!b.companyId) return; // government/unlinked bookings have no portal users
|
||||
void this.inbox.notify({
|
||||
recipients: { companyId: b.companyId },
|
||||
@@ -209,11 +245,19 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
secured(
|
||||
b: Booking,
|
||||
reason: 'paid' | 'gov' | 'shipping_line',
|
||||
scheduleId?: string | null,
|
||||
): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
reason === 'gov'
|
||||
? ' (government)'
|
||||
: reason === 'shipping_line'
|
||||
? ' (shipping line)'
|
||||
: ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
|
||||
@@ -4531,7 +4531,10 @@ export class TrainSchedulingService {
|
||||
(b) =>
|
||||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||||
!SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
|
||||
!b.isGovernment,
|
||||
!b.isGovernment &&
|
||||
// Shipping-line bookings pay later on the credit ledger — never PAID
|
||||
// up front, schedulable from accept (FULLY_EXECUTED) like government.
|
||||
!b.shippingLineCompanyId,
|
||||
);
|
||||
if (invalidStatus.length) {
|
||||
const statuses = [...new Set(invalidStatus.map((b) => b.status))];
|
||||
@@ -8647,7 +8650,12 @@ export class TrainSchedulingService {
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const eligible = linkedBookings.filter(
|
||||
(b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
|
||||
(b) =>
|
||||
SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') ||
|
||||
b.isGovernment ||
|
||||
// Shipping-line bookings board without paying up front — their charge
|
||||
// sits on the credit ledger, so accept (FULLY_EXECUTED) is boardable.
|
||||
Boolean(b.shippingLineCompanyId),
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user