shipping line

This commit is contained in:
Marshal
2026-08-13 18:56:52 +00:00
parent 0e00a98ef3
commit b9ba830a09
48 changed files with 6766 additions and 639 deletions

View File

@@ -11,6 +11,7 @@ import { Booking } from './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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
/**
@@ -58,10 +59,16 @@ export class BookingLifecycleNotifierService {
// Both channels come from the same resolver: the company row's own columns
// are only half the story (see companyNotifyEmailExpr), and reading them off
// the loaded entity silently dropped every mail to a company whose address
// lives in `attributes`.
const { phone, email } = b.companyId
? await resolveCompanyNotifyContact(this.dataSource, b.companyId)
: { phone: null, email: null };
// lives in `attributes`. A shipping-line booking has NO company — its
// contact lives on the shipping_line_companies row itself.
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 {
@@ -82,13 +89,44 @@ export class BookingLifecycleNotifierService {
}
}
/** 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 rather than the customer
* one (its routes live under /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.BOOKING_STATUS,
title,
body,
data: { bookingId: b.id, reference: b.reference },
...overrides,
// After the spread: overrides carry customer links — the bell must
// land a shipping 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 },
@@ -176,13 +214,23 @@ export class BookingLifecycleNotifierService {
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
// A shipping line's next move is BOOKING (cargo + shipment day), not the
// customer's operation-request step — say so, or the message points at a
// flow their portal does not have.
const msg = b.shippingLineCompanyId
? `Documents for booking ${b.reference} are approved. ` +
`You can now book your shipment — enter the cargo and shipment day from the portal.`
: `Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
this.inApp(
b,
b.shippingLineCompanyId
? 'Documents approved — book your shipment'
: 'Document approval finalized',
msg,
{ type: NotificationType.CLEARANCE_DECISION },
);
}
/** Intercity documents approved → booking waits in the ride-along pool. */
@@ -234,11 +282,19 @@ export class BookingLifecycleNotifierService {
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
const msg =
`Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
// No invoice and no pay window for a shipping line — the charge sits on
// its credit account and the booking boards its dedicated train directly.
const msg = b.shippingLineCompanyId
? `Your booking ${b.reference} has been accepted. The charge has been ` +
`recorded on your credit account and your shipment is being placed on its train.`
: `Your operation request for booking ${b.reference} has been accepted. ` +
`An invoice has been prepared — watch for the payment window to secure your slot.`;
void this.notifyContact(b, msg, 'OPERATION ACCEPTED');
this.inApp(b, 'Operation request accepted', msg);
this.inApp(
b,
b.shippingLineCompanyId ? 'Booking accepted' : 'Operation request accepted',
msg,
);
}
/**

View File

@@ -39,6 +39,8 @@ import { ClearanceWorkflowService } from '../contracts/clearance-workflow.servic
import { ContractDocPhase } from '@edr/types';
import { BookingInvoiceService } from "./booking-invoice.service";
// Type-only: the DI edge stays event-based to keep the module graph acyclic.
import type { ShippingLineBookingAcceptedPayload } from "../shipping-lines/shipping-line-credits.service";
@Injectable()
export class BookingTransitionService {
@@ -1012,7 +1014,14 @@ export class BookingTransitionService {
// The customer's train pick only exists for export rail; it rides the
// booking through the space checks below AND is persisted so the accept /
// reserve path locks onto that train (pickExportSchedule honors it).
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
// Shipping-line completions (bypassDayPool) pick among the line's own
// dedicated trains — already validated by the caller, so the pick is
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
// auto-assigned one. Both portal flows (clearance + contract completion)
// surface a picker, so a missing id is an invalid submission, not a
@@ -1216,10 +1225,22 @@ export class BookingTransitionService {
// booking page correctly still showed it as not payable. The batch engine
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
// and the real deadline are created — matching the portal's `canPay` gate.
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
//
// Shipping-line bookings mint NO invoice at all: they have no company row
// to bill (the invoices FK requires one) and they pay on the credit ledger
// — the charge was recorded at completion, and Finance bills a batch of
// credits later through ShippingLineCreditsService.generateInvoice.
if (booking.shippingLineCompanyId) {
this.logger.log(
`Skipping invoice for shipping-line booking ${booking.reference}:${booking.id} — billed later from the credit ledger`,
);
} else {
const invoice =
await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
}
// TODO: road (truck) orders are an incomplete feature — they stop at the
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
@@ -1233,6 +1254,7 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
const roadFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(roadFresh);
this.notifier.operationAccepted(roadFresh);
return roadFresh;
}
@@ -1269,11 +1291,44 @@ export class BookingTransitionService {
// batch runs after the window closes + staff document review, never at accept
// time. (Legacy pre-migration schedules with no window phase are still served
// by the periodic legacy fill.)
//
// EXCEPT shipping-line bookings: they pay later on the credit ledger, so
// no pay window exists to wait for — accept places them straight onto
// their company's dedicated train and its wagons. Non-fatal on purpose:
// the accept has committed; an allocation hiccup leaves the booking in
// the day pool for the batch engine / staff instead of failing the accept.
if (booking.shippingLineCompanyId) {
try {
await this.bookingBatchService.allocateShippingLineAccepted(booking.id);
} catch (err) {
this.logger.warn(
`Auto-allocation failed for shipping-line booking ${booking.reference}:${booking.id} — left in the day pool: ${(err as Error).message}`,
);
}
}
const trainFresh = await this.bookingsService.findById(booking.id);
this.emitShippingLineAccepted(trainFresh);
this.notifier.operationAccepted(trainFresh);
return trainFresh;
}
/**
* A shipping-line booking becomes debt at THIS moment — Operations accepted
* it — not at completion/pricing. Event, not a service call:
* ShippingLineCreditsService listens (`shipping_line_booking.accepted`), and
* importing its module here would close a module cycle. Emitted after the
* accept has fully committed (including the export-capacity path, which can
* still revert the status above), so a failed accept never creates debt.
*/
private emitShippingLineAccepted(booking: Booking): void {
if (!booking.shippingLineCompanyId) return;
this.events.emit("shipping_line_booking.accepted", {
bookingId: booking.id,
reference: booking.reference,
amount: Number(booking.totalAmount),
} satisfies ShippingLineBookingAcceptedPayload);
}
async enrichBookingResponse(booking: Booking): Promise<
Booking & {
latestChangeRequestNote?: string | null;