mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
shipping line
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user