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

@@ -13,6 +13,9 @@ import { logCtx } from "@edr/api-common";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
// Entity-only import (no module edge): portal reads resolve shipping-line
// payers straight off the table.
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
import { EimsConfig } from "../../config/eims.config";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
@@ -574,23 +577,61 @@ export class BillingService {
});
}
/** Invoices for the signed-in customer; empty when they have no company. */
/**
* Resolve a shipping-line company from the signed-in user (null for ordinary
* customers). Queried straight off the entity rather than through
* ShippingLineCompaniesService — that module already imports billing, so a
* service edge back would deepen the forwardRef cycle for one lookup.
*/
private async resolveShippingLineCompanyId(
userId: string,
): Promise<string | null> {
const line = await this.dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { userId } });
return line?.id ?? null;
}
/**
* Invoices for the signed-in portal user; empty when they have no company.
* A payer is either a customer company or a shipping line (enforced by the
* DB's single-payer check), so the two lookups cannot both match.
*/
async findForUser(
userId: string,
filter: { source?: string; sourceId?: string } = {},
): Promise<Invoice[]> {
const companyId = await this.resolveCompanyId(userId);
return companyId ? this.findByCompany(companyId, filter) : [];
if (companyId) return this.findByCompany(companyId, filter);
const shippingLineCompanyId =
await this.resolveShippingLineCompanyId(userId);
if (!shippingLineCompanyId) return [];
return this.invoices.findAll({
where: {
shippingLineCompanyId,
...(filter.source ? { source: filter.source } : {}),
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
},
order: { createdAt: "DESC" },
});
}
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
/** Payer-scoped invoice detail (+ lines); 404 when not owned by the user. */
async findByIdForUser(
id: string,
userId: string,
): Promise<Invoice & { lines: InvoiceLine[] }> {
const companyId = await this.resolveCompanyId(userId);
const invoice = await this.findById(id);
if (!companyId || invoice.companyId !== companyId) {
const ownedByCompany =
invoice.companyId != null &&
invoice.companyId === (await this.resolveCompanyId(userId));
const ownedByShippingLine =
!ownedByCompany &&
invoice.shippingLineCompanyId != null &&
invoice.shippingLineCompanyId ===
(await this.resolveShippingLineCompanyId(userId));
if (!ownedByCompany && !ownedByShippingLine) {
throw new NotFoundException(`Invoice ${id} not found`);
}
return invoice;

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;

View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity";
/**
* Notification target for a shipping-line booking.
*
* A shipping line is NOT a `companies` row: the company IS the account — one
* IAM user (`userId`), and the contact details live on the
* `shipping_line_companies` row itself. So the customer resolvers
* (external_profiles fan-out, company attributes email) never apply; this is
* the one lookup every shipping-line notification routes through.
*
* Entity-only import — safe from any module graph: notifiers already own a
* DataSource and need no service from the shipping-lines module.
*/
export async function resolveShippingLineNotifyTarget(
dataSource: DataSource,
shippingLineCompanyId: string,
): Promise<{
userId: string | null;
phone: string | null;
email: string | null;
}> {
const line = await dataSource
.getRepository(ShippingLineCompany)
.findOne({ where: { id: shippingLineCompanyId } });
return {
userId: line?.userId ?? null,
phone: line?.phoneNumber ?? null,
email: line?.email ?? null,
};
}

View File

@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsInt,
@@ -15,15 +16,61 @@ import {
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
/**
* One physical container on a line — number, seal, VGM and its per-container
* handling switches. Same shape the customer shipment form submits.
*/
export class CompleteShippingLineContainerUnitDto {
@ApiProperty({ example: "MSCU1234567" })
@IsString()
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
@ApiProperty({ minimum: 0, description: "VGM of this container, tons." })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmTons!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
isReefer?: boolean;
}
/**
* One container line the shipping line ships — container type + count, the
* same shape the customer one-time form collects (no per-unit ISO numbers;
* those are captured downstream at yard operations, as for customers).
* same shape the customer one-time form collects. When `units` is sent (the
* full booking page), per-container numbers/seals/VGM and handling switches
* are persisted exactly like the customer shipment form; without it (legacy
* modal shape) the line-level counts stand alone.
*/
export class CompleteShippingLineContainerLineDto {
@ApiProperty({ format: "uuid", description: "Container type being shipped." })
@ApiPropertyOptional({
format: "uuid",
description:
"Container type being shipped. Optional when containerSize is sent — the server resolves the type from the size.",
})
@IsOptional()
@IsUUID()
containerTypeId!: string;
containerTypeId?: string;
@ApiPropertyOptional({
description:
'Container size, e.g. "20ft" | "40ft". The server maps it to the configured container type (reefer variant when the line carries reefer boxes) — so the client never needs the type catalog.',
})
@IsOptional()
@IsString()
containerSize?: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@@ -51,6 +98,17 @@ export class CompleteShippingLineContainerLineDto {
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
@ApiPropertyOptional({
type: [CompleteShippingLineContainerUnitDto],
description:
"Per-container details. When present, the handling counts and VGM are derived from these rows.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompleteShippingLineContainerUnitDto)
units?: CompleteShippingLineContainerUnitDto[];
}
/**
@@ -66,6 +124,15 @@ export class CompleteShippingLineBookingDto {
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
format: "uuid",
description:
"Which of the line's dedicated trains this booking rides. Required when more than one departs on the chosen day; implicit with a single departure.",
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
@@ -99,6 +166,26 @@ export class CompleteShippingLineBookingDto {
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: hazardous portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: refrigerated portion of the cargo.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
bulkReeferQuantity?: number;
@ApiPropertyOptional({ description: "What the containers carry." })
@IsOptional()
@IsString()

View File

@@ -38,6 +38,40 @@ export class GenerateCreditInvoiceDto {
dueInDays?: number;
}
/** Finance's request for a manual action on a credit invoice (maker step). */
export class RequestInvoiceActionDto {
@ApiProperty({
description:
"Why the action is needed. Shown to the approver and kept for audit.",
example: "Paid by bank transfer, slip #TT-4491",
})
@IsString()
@MinLength(3)
@MaxLength(500)
reason!: string;
@ApiPropertyOptional({
description:
"Offline payment reference (bank slip / transfer number). MARK_PAID requests only.",
example: "TT-4491",
})
@IsOptional()
@IsString()
@MaxLength(255)
paymentReference?: string;
}
/** The decision on a pending request (approve and reject routes). */
export class DecideInvoiceActionDto {
@ApiPropertyOptional({
description: "Decision note. Required when rejecting.",
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
/** Write-off of a single unbilled credit. */
export class CancelCreditDto {
@ApiProperty({

View File

@@ -0,0 +1,82 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Invoice } from "../../billing/entities/invoice.entity";
/** What finance asked to do to a shipping-line credit invoice. */
export enum ShippingLineInvoiceActionType {
/** Record a full offline settlement (paid outside the gateway). */
MarkPaid = "MARK_PAID",
/** Void the invoice; its credits return to the unbilled pool. */
Cancel = "CANCEL",
}
export enum ShippingLineInvoiceActionStatus {
Pending = "PENDING",
Approved = "APPROVED",
Rejected = "REJECTED",
}
/**
* Makerchecker for manual actions on shipping-line credit invoices.
*
* Marking an invoice paid by hand erases real debt, and cancelling one
* releases its credits back to the unbilled pool — either done unilaterally is
* a one-person fraud path. So finance REQUESTS the action (one permission)
* and a chief APPROVES or REJECTS it (a separate permission, different
* person). Every request is kept, decided or not: the table is the audit
* trail of who asked, who decided, and why.
*/
@Entity({ schema: "freight", name: "shipping_line_invoice_approvals" })
@Index(["invoiceId", "status"])
export class ShippingLineInvoiceApproval extends BaseEntity {
@Column({ name: "invoice_id", type: "uuid" })
invoiceId!: string;
@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoice_id" })
invoice?: Invoice;
@Column({ name: "action", type: "enum", enum: ShippingLineInvoiceActionType })
action!: ShippingLineInvoiceActionType;
@Column({
name: "status",
type: "enum",
enum: ShippingLineInvoiceActionStatus,
default: ShippingLineInvoiceActionStatus.Pending,
})
status!: ShippingLineInvoiceActionStatus;
/** IAM user id of the finance staff who raised the request. */
@Column({ name: "requested_by", type: "uuid" })
requestedBy!: string;
/** Why the action is needed; shown to the approver, kept for audit. */
@Column({ name: "reason", type: "varchar", length: 500 })
reason!: string;
/** Offline payment reference (bank slip no. etc.) for MARK_PAID requests. */
@Column({
name: "payment_reference",
type: "varchar",
length: 255,
nullable: true,
})
paymentReference?: string | null;
/** IAM user id of the chief who approved/rejected; null while pending. */
@Column({ name: "decided_by", type: "uuid", nullable: true })
decidedBy?: string | null;
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
decidedAt?: Date | null;
@Column({
name: "decision_note",
type: "varchar",
length: 500,
nullable: true,
})
decisionNote?: string | null;
}

View File

@@ -1,5 +1,13 @@
import { CurrentUser } from "@edr/api-common";
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
@@ -37,6 +45,54 @@ export class ShippingLineBookingCompletionController {
return this.completionService.availableDaysMine(user.id, id);
}
@Get(":id/trains")
@PortalCustomer()
@ApiOperation({
summary:
"The line's dedicated trains on the booking's lane for a shipment day, each with per-wagon-type free space — for the completion form's train picker. Cargo context (sizes/cargoTypeId/wagons) refines the availability.",
})
async trainsForDay(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date?: string,
@Query("sizes") sizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("wagons") wagons?: string,
) {
return this.completionService.trainsForDayMine(user.id, id, date, {
containerSizes: sizes ? sizes.split(",").filter(Boolean) : undefined,
cargoTypeId: cargoTypeId || undefined,
wagons: wagons ? Number(wagons) : undefined,
});
}
@Post(":id/price-preview")
@PortalCustomer()
@ApiOperation({
summary:
"Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.",
})
async pricePreview(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.completionService.previewPriceMine(user.id, id, dto);
}
@Get(":id/operations")
@PortalCustomer()
@ApiOperation({
summary:
"Operations view of the booking: the train it rides (assigned or requested) and the wagons allocated to it.",
})
async operations(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.completionService.operationsMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({

View File

@@ -11,12 +11,18 @@ import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { eatDay } from "../train-scheduling/batch-window.util";
import {
BookingBatchService,
type TrainOptionCargoOverrides,
} from "../train-scheduling/booking-batch.service";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import {
@@ -49,6 +55,7 @@ export class ShippingLineBookingCompletionService {
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService,
) {}
@@ -108,6 +115,28 @@ export class ShippingLineBookingCompletionService {
return closesAt.getTime() > Date.now();
}
/**
* The line's dedicated trains on the booking's lane for one shipment day,
* each with per-wagon-type free space — the completion form's train picker.
* A booking rides ONE schedule, so with several departures that day the
* line picks which; the pick is validated again at complete time.
*/
async trainsForDayMine(
userId: string,
bookingId: string,
date: string | undefined,
overrides?: TrainOptionCargoOverrides,
) {
const booking = await this.requireOwnBooking(userId, bookingId);
if (!booking.shippingLineCompanyId) return [];
return this.bookingBatchService.dedicatedTrainOptionsForDay(
booking,
date ? eatDay(new Date(date)) : null,
booking.shippingLineCompanyId,
overrides,
);
}
/**
* Days the shipping line may pick as the shipment day.
*
@@ -174,12 +203,32 @@ export class ShippingLineBookingCompletionService {
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
);
let bypassDayPool = false;
let requestedTrainScheduleId: string | null = null;
if (dedicatedOnDay.length > 0) {
if (!dedicatedOnDay.some((s) => this.isStillOpen(s))) {
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
if (openOnDay.length === 0) {
throw new BadRequestException(
"Booking for your train on this day has closed — the cut-off before departure has passed.",
);
}
// A booking rides ONE schedule. Several departures that day → the line
// must say which; a single one is picked implicitly. The id comes from
// the request, so it is validated against the day's own trains.
if (dto.trainScheduleId) {
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
if (!picked) {
throw new BadRequestException(
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
);
}
requestedTrainScheduleId = picked.id;
} else if (openOnDay.length === 1) {
requestedTrainScheduleId = openOnDay[0].id;
} else {
throw new BadRequestException(
"More than one of your trains departs that day — select which train this booking rides.",
);
}
// The day is backed by the line's own train, which every customer pool
// deliberately excludes — so the day-pool gate downstream must not run.
bypassDayPool = true;
@@ -260,14 +309,35 @@ export class ShippingLineBookingCompletionService {
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Bulk handling portions — sized against the cargo, billed by pricing.
...(booking.freightType === "BULK"
? {
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
}
: {}),
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous: (dto.containers ?? []).some(
(line) => Number(line.hazardousQuantity ?? 0) > 0,
),
// Completion fixes the cargo — and therefore the price — so it is also
// where the billing currency is chosen.
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Same for reefer: the rule engine's REEFER trigger fires on the
// booking-level flag (or a reefer container TYPE) — a ticked reefer
// switch on a standard box only sets the per-line count, so without
// this flag the surcharge silently never bills.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
// Shipping lines are always billed in ETB: the charge lands on the
// ETB credit ledger, so the currency is enforced here rather than
// trusted from the payload.
paymentCurrency: "ETB",
} as never);
const loaded = await this.bookingsRepository.findOne({
@@ -305,14 +375,10 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers,
);
// The charge goes on the line's credit ledger ("use now, pay later") —
// idempotent per booking, so a retried completion cannot double the debt.
await this.creditsService.recordCredit({
bookingId,
amount: computed.totalAmount,
currency: computed.currency,
description: `Freight service — booking ${booking.reference}`,
});
// No credit is recorded here: completion only REQUESTS the operation.
// The charge lands on the line's ledger when Operations accepts —
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
// request that is returned or never accepted creates no debt.
}
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
@@ -321,11 +387,307 @@ export class ShippingLineBookingCompletionService {
return this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
null,
requestedTrainScheduleId,
bypassDayPool ? { bypassDayPool: true } : undefined,
);
}
/**
* Authoritative price preview for the completion form's confirm step: the
* SAME compute the completion itself runs, over an in-memory probe shaped
* exactly like completeMine would persist the booking — so the figure the
* shipping line confirms is line-for-line what it will owe.
*
* The result is not advisory-only: the breakdown is saved on the booking and
* the rate snapshots are (re)written, so every re-preview refreshes them.
* Nothing else is persisted — no cargo rows, no credit, no transition.
*/
async previewPriceMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const booking = await this.requireOwnBooking(userId, bookingId, {
bookingContainers: true,
});
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be priced.",
);
}
// In-memory cargo, mirroring what completeMine persists.
let probeContainers: Partial<BookingContainer>[] = [];
let bulkFields: Record<string, unknown> = {};
if (booking.freightType === "CONTAINER") {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException(
"At least one container line is required.",
);
}
for (const line of lines) {
const containerType = await this.resolveContainerType(line);
const figures = this.lineFigures(line);
probeContainers.push({
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
});
}
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(`Cargo type ${dto.cargoTypeId} not found`);
}
bulkFields = {
cargoTypeId: dto.cargoTypeId,
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
bulkTotalWeightTons: Number(dto.cargoWeightTons),
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
};
probeContainers = [];
}
// Prototype-preserving clone so entity getters keep working — the same
// probe trick the contract preview uses.
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{
bookingContainers: probeContainers,
paymentCurrency: "ETB",
isHazardous:
(dto.containers ?? []).some(
(line) =>
Number(line.hazardousQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isHazardous),
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
// Mirrors completeMine: without the booking-level flag the engine's
// REEFER trigger never fires for reefer opt-ins on standard boxes,
// and the quote would show base freight only.
isReefer:
(dto.containers ?? []).some(
(line) =>
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer),
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
...bulkFields,
},
) as Booking;
const computed =
await this.bookingPricingService.computePriceForBooking(probe);
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
// Persist the quoted figure: breakdown on the booking, snapshots of the
// rates it was built from. createPricingSnapshots clears the previous
// artifacts first, so a re-preview replaces the old quote rather than
// stacking a second one.
await this.bookingsRepository.update(bookingId, {
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
return {
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
/**
* What operations has done with the booking so far: the train it rides
* (assigned, or the requested one before assignment) and the wagons the
* batch engine allocated to it, with any container numbers loaded per wagon.
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
*/
async operationsMine(userId: string, bookingId: string) {
const booking = await this.requireOwnBooking(userId, bookingId);
const manager = this.bookingsRepository.manager;
const scheduleId =
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
let train: Record<string, unknown> | null = null;
if (scheduleId) {
const schedule = await manager.getRepository(TrainSchedule).findOne({
where: { id: scheduleId },
relations: { originStation: true, destinationStation: true },
});
if (schedule) {
train = {
id: schedule.id,
reference: schedule.reference,
trainNumber: schedule.trainNumber,
status: schedule.status,
direction: schedule.direction,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originLabel:
schedule.originStation?.label ??
schedule.originStation?.code ??
"Origin",
destinationLabel:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
"Destination",
// Whether this is the confirmed assignment or still the request.
assigned: Boolean(booking.trainScheduleId),
};
}
}
const allocations = await manager
.getRepository(WagonBookingAllocation)
.find({
where: { bookingId },
relations: {
trainSetWagon: { wagonType: true, physicalWagon: true },
containerItems: true,
},
order: { createdAt: "ASC" },
});
const wagons = allocations.map((allocation) => ({
id: allocation.id,
status: allocation.status,
loadType: allocation.loadType,
allocatedWeightTons: Number(allocation.allocatedWeightTons),
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
wagonType:
allocation.trainSetWagon?.wagonType?.name ??
allocation.trainSetWagon?.wagonType?.code ??
null,
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
}));
return { train, wagons };
}
/**
* Resolve a line's container type: by id when the payload carries one, else
* from the size string ("40ft" → the active 40ft type, preferring the reefer
* variant when the line ships reefer boxes). Resolution lives HERE, not in
* the portal, so a slow or failed catalog fetch can never block a booking
* with a phantom "type not configured" error — mirrors the customer flow's
* server-side size→type mapping.
*/
private async resolveContainerType(line: {
containerTypeId?: string;
containerSize?: string;
reeferQuantity?: number;
units?: { isReefer?: boolean }[];
}): Promise<ContainerType> {
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
if (line.containerTypeId) {
const byId = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!byId) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
return byId;
}
const sizeFt = parseInt(line.containerSize ?? "", 10);
if (!Number.isFinite(sizeFt)) {
throw new BadRequestException(
"Each container line needs a containerTypeId or a containerSize.",
);
}
const candidates = await containerTypeRepo.find({
where: { isActive: true },
});
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
if (!ofSize.length) {
throw new BadRequestException(
`No ${sizeFt}ft container type is configured — please contact Operations.`,
);
}
const wantsReefer =
Number(line.reeferQuantity ?? 0) > 0 ||
(line.units ?? []).some((u) => u.isReefer);
if (wantsReefer) {
const reefer = ofSize.find((ct) => ct.isReefer);
if (reefer) return reefer;
}
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
}
/**
* A line's derived figures. With per-container rows (the full booking page),
* counts and VGM come FROM the rows — each container's switches are the
* source of truth. Without them, the line-level figures stand alone.
*/
private lineFigures(line: {
quantity: number;
vgmPerUnitTons?: number;
hazardousQuantity?: number;
reeferQuantity?: number;
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
}) {
const units = line.units ?? [];
const hazardous = units.length
? units.filter((u) => u.isHazardous).length
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
const reefer = units.length
? units.filter((u) => u.isReefer).length
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const totalVgm = units.length
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
const vgmPerUnit = units.length
? totalVgm / units.length
: Number(line.vgmPerUnitTons ?? 0);
return { hazardous, reefer, totalVgm, vgmPerUnit };
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
@@ -341,27 +703,18 @@ export class ShippingLineBookingCompletionService {
throw new BadRequestException("At least one container line is required.");
}
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
const unitRepo =
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
for (const line of lines) {
const containerType = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
const hazardous = Math.min(
Number(line.hazardousQuantity ?? 0),
line.quantity,
);
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
await containerRepo.save(
const containerType = await this.resolveContainerType(line);
// Counts and VGM derived by lineFigures — the same math the price
// preview runs, so the persisted cargo always matches the quote.
const units = line.units ?? [];
const figures = this.lineFigures(line);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
@@ -369,16 +722,31 @@ export class ShippingLineBookingCompletionService {
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: hazardous,
reeferQuantity: reefer,
hazardousQuantity: figures.hazardous,
reeferQuantity: figures.reefer,
returnQuantity: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
let sortOrder = 0;
for (const unit of units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber.trim().toUpperCase(),
sealNumber: unit.sealNumber?.trim() || null,
vgmTons: Number(unit.vgmTons ?? 0),
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn: false,
sortOrder: sortOrder++,
}),
);
}
}
}

View File

@@ -319,8 +319,16 @@ export class ShippingLineBookingsService {
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
// Yards are loaded so the portal can render the lane without a second
// lookup — they are set at initiate time from the chosen route.
relations: { originYard: true, destinationYard: true, serviceType: true },
// lookup — they are set at initiate time from the chosen route. Cargo
// (container lines + units, bulk cargo type) rides along for the detail
// page's cargo tab once the booking is completed.
relations: {
originYard: true,
destinationYard: true,
serviceType: true,
cargoType: true,
bookingContainers: { containerType: true, units: true },
},
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
@@ -330,7 +338,24 @@ export class ShippingLineBookingsService {
.getRepository(BookingDocumentReview)
.count({ where: { bookingId, status: "QUERIED" } });
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
// The note Operations wrote when returning the request — the line has to
// read it to know what to fix. Only the latest CHANGES_REQUESTED note is
// exposed; the other review-note types are staff-internal.
const changeNote =
booking.status === "OPERATION_CHANGES_REQUESTED"
? await this.bookingsRepository.manager
.getRepository(BookingReviewNote)
.findOne({
where: { bookingId, type: "CHANGES_REQUESTED" },
order: { createdAt: "DESC" },
})
: null;
return {
...booking,
hasQueriedDocuments: queriedCount > 0,
operationChangeNote: changeNote?.note ?? null,
};
}
/**

View File

@@ -49,7 +49,12 @@ export class ShippingLineCompaniesController {
}
@Get()
@BookingStaff(FREIGHT_PERMS.shippingLines.view)
// OR'd: the credits view needs this list as its line picker, so holding
// shipping_line_credits:view alone is enough to read it.
@BookingStaff([
FREIGHT_PERMS.shippingLines.view,
FREIGHT_PERMS.shippingLineCredits.view,
])
@ApiOperation({ summary: "List shipping lines (paginated)" })
async list(
@Query("page") page?: string,

View File

@@ -9,6 +9,8 @@ import { Booking } from "../bookings/entities/booking.entity";
import { OtpModule } from "../otp/otp.module";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
import { ShippingLineInvoiceApproval } from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
import { ShippingLineCompaniesController } from "./shipping-line-companies.controller";
@@ -25,6 +27,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
TypeOrmModule.forFeature([
ShippingLineCompany,
ShippingLineCredit,
ShippingLineInvoiceApproval,
User,
Booking,
]),
@@ -48,6 +51,7 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
ShippingLineBookingsService,
ShippingLineCreditsService,
ShippingLineCreditsRepository,
ShippingLineInvoiceApprovalsRepository,
],
// Exported so whatever prices a shipping-line booking can record the charge.
exports: [ShippingLineCompaniesService, ShippingLineCreditsService],

View File

@@ -1,4 +1,5 @@
import { CurrentUser } from "@edr/api-common";
import { Freight } from "@edr/types";
import {
Body,
Controller,
@@ -14,9 +15,12 @@ import { BookingStaff, PortalCustomer } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import {
CancelCreditDto,
DecideInvoiceActionDto,
GenerateCreditInvoiceDto,
RequestInvoiceActionDto,
} from "./dto/shipping-line-credit.dto";
import { ShippingLineCreditStatus } from "./entities/shipping-line-credit.entity";
import { ShippingLineInvoiceActionType } from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
interface CurrentIamUser {
@@ -37,6 +41,159 @@ interface CurrentIamUser {
export class ShippingLineCreditsController {
constructor(private readonly credits: ShippingLineCreditsService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"The whole credit ledger across every shipping line (paginated), optionally filtered by line and/or status.",
})
async listAll(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: ShippingLineCreditStatus,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listAll(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status,
shippingLineId,
);
}
// Declared before the parameterised staff routes so "summary" is never
// captured as a shipping-line id.
@Get("summary")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Outstanding totals across every shipping line, or one line when shippingLineId is given.",
})
async summaryAll(
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.summary(shippingLineId);
}
// Declared before ":shippingLineId" so "invoices" is never captured as an id.
@Get("invoices")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Credit invoices across every shipping line (paginated), each with any pending manual-action request.",
})
async listInvoices(
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("status") status?: string,
@Query("shippingLineId", new ParseUUIDPipe({ optional: true }))
shippingLineId?: string,
) {
return this.credits.listCreditInvoices(
page ? Number(page) : 1,
pageSize ? Number(pageSize) : 20,
status as Freight.InvoiceStatus | undefined,
shippingLineId,
);
}
@Get("invoice-actions/pending")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.view)
@ApiOperation({
summary:
"Undecided manual-action requests for a batch of invoices (one lookup for a list page).",
})
async pendingInvoiceActions(@Query("invoiceIds") invoiceIds?: string) {
const ids = (invoiceIds ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
return this.credits.pendingInvoiceActions(ids);
}
// ── Makerchecker on credit invoices ──────────────────────────────────────
// Request and approve are DIFFERENT permissions, and the service refuses a
// decision by the requester — marking debt paid or voiding an invoice is
// never a one-person action.
@Post("invoices/:invoiceId/mark-paid-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceMarkPaid)
@ApiOperation({
summary:
"Request recording a full offline payment against a credit invoice (awaits chief approval).",
})
async requestMarkPaid(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.MarkPaid,
user.id,
dto.reason,
dto.paymentReference,
);
}
@Post("invoices/:invoiceId/cancel-request")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceCancel)
@ApiOperation({
summary:
"Request voiding a credit invoice — its credits return to the unbilled pool (awaits chief approval).",
})
async requestCancel(
@Param("invoiceId", ParseUUIDPipe) invoiceId: string,
@Body() dto: RequestInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.requestInvoiceAction(
invoiceId,
ShippingLineInvoiceActionType.Cancel,
user.id,
dto.reason,
);
}
@Post("invoice-actions/:approvalId/approve")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceApprove)
@ApiOperation({
summary:
"Approve a pending invoice request — executes the offline settlement or the cancellation.",
})
async approveInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
true,
dto.note,
);
}
@Post("invoice-actions/:approvalId/reject")
@BookingStaff(FREIGHT_PERMS.shippingLineCredits.invoiceReject)
@ApiOperation({
summary: "Reject a pending invoice request — nothing is changed.",
})
async rejectInvoiceAction(
@Param("approvalId", ParseUUIDPipe) approvalId: string,
@Body() dto: DecideInvoiceActionDto,
@CurrentUser() user: CurrentIamUser,
) {
return this.credits.decideInvoiceAction(
approvalId,
user.id,
false,
dto.note,
);
}
// Declared before the parameterised staff routes so "me" is never captured
// as a shipping-line id.
@Get("me")

View File

@@ -76,25 +76,32 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
/**
* Derived debt — never a stored column. Grouped in one query so the detail
* page does not fan out per status.
* page does not fan out per status. Without a line id it totals every line —
* the back-office overview figure.
*/
async outstandingFor(
shippingLineCompanyId: string,
shippingLineCompanyId?: string,
): Promise<OutstandingTotals> {
const rows = await this.credits
const qb = this.credits
.createQueryBuilder("credit")
.select("credit.status", "status")
.addSelect("COALESCE(SUM(credit.amount), 0)", "amount")
.addSelect("COUNT(*)", "count")
.where("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
})
.andWhere("credit.status IN (:...statuses)", {
.where("credit.status IN (:...statuses)", {
statuses: [...OUTSTANDING_CREDIT_STATUSES],
})
.andWhere("credit.deletedAt IS NULL")
.groupBy("credit.status")
.getRawMany<{ status: string; amount: string; count: string }>();
.groupBy("credit.status");
if (shippingLineCompanyId) {
qb.andWhere("credit.shippingLineCompanyId = :shippingLineCompanyId", {
shippingLineCompanyId,
});
}
const rows = await qb.getRawMany<{
status: string;
amount: string;
count: string;
}>();
const totals = (status: ShippingLineCreditStatus) => {
const row = rows.find((r) => r.status === status);
@@ -117,19 +124,22 @@ export class ShippingLineCreditsRepository extends BaseRepository<ShippingLineCr
};
}
/** Paginated ledger for one line — every credit, whatever its status. */
/**
* Paginated ledger — every credit, whatever its status. Scoped to one line
* when an id is given, across all lines otherwise.
*/
findAllPaginated(
shippingLineCompanyId: string,
shippingLineCompanyId: string | undefined,
skip: number,
take: number,
status?: ShippingLineCreditStatus,
): Promise<[ShippingLineCredit[], number]> {
return this.credits.findAndCount({
where: {
shippingLineCompanyId,
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
...(status ? { status } : {}),
},
relations: { booking: true, invoice: true },
relations: { booking: true, invoice: true, shippingLineCompany: true },
order: { createdAt: "DESC" },
skip,
take,

View File

@@ -77,6 +77,14 @@ describe("ShippingLineCreditsService", () => {
service = new ShippingLineCreditsService(
dataSource as never,
creditsRepo as never,
// Approvals repo — only the invoice makerchecker paths touch it.
{
findPendingByInvoice: jest.fn(),
findPendingByInvoiceIds: jest.fn().mockResolvedValue([]),
findByIdForUpdate: jest.fn(),
create: jest.fn(),
update: jest.fn(),
} as never,
billing as never,
shippingLines as never,
);

View File

@@ -21,7 +21,14 @@ import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
ShippingLineInvoiceActionType,
} from "./entities/shipping-line-invoice-approval.entity";
import { ShippingLineCreditsRepository } from "./shipping-line-credits.repository";
import { ShippingLineInvoiceApprovalsRepository } from "./shipping-line-invoice-approvals.repository";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
/** A charge to record against a shipping line's booking. */
@@ -39,6 +46,18 @@ export interface GenerateCreditInvoiceOptions {
dueInDays?: number;
}
/**
* Emitted by the booking-transition accept path for shipping-line bookings.
* An event rather than a service call: BookingsModule cannot import the
* shipping-line modules without closing a module cycle.
*/
export interface ShippingLineBookingAcceptedPayload {
bookingId: string;
reference: string;
/** The booking's priced total, frozen at completion time. */
amount: number;
}
/**
* The credit ledger for shipping lines — "use the service now, pay later".
*
@@ -66,6 +85,7 @@ export class ShippingLineCreditsService {
constructor(
private readonly dataSource: DataSource,
private readonly credits: ShippingLineCreditsRepository,
private readonly approvals: ShippingLineInvoiceApprovalsRepository,
private readonly billing: BillingService,
private readonly shippingLines: ShippingLineCompaniesService,
) {}
@@ -144,6 +164,32 @@ export class ShippingLineCreditsService {
return manager ? run(manager) : this.dataSource.transaction(run);
}
/**
* The moment a shipping-line booking becomes debt: Operations accepted it.
* Swallows its own failures with a loud log instead of throwing — the accept
* has already committed, and failing the staff response for a ledger write
* would present a succeeded accept as an error. `recordCredit` is idempotent
* per booking, so a re-accepted (previously reverted) booking cannot double
* the debt.
*/
@OnEvent("shipping_line_booking.accepted")
async onBookingAccepted(
payload: ShippingLineBookingAcceptedPayload,
): Promise<void> {
try {
await this.recordCredit({
bookingId: payload.bookingId,
amount: payload.amount,
// Shipping lines are always billed in ETB (enforced at completion).
currency: "ETB",
});
} catch (err) {
this.logger.error(
`Failed to record credit for accepted shipping-line booking ${payload.reference} (${payload.bookingId}): ${(err as Error).message} — the debt is NOT on the ledger; record it manually or re-trigger.`,
);
}
}
// ── 2. Bill ────────────────────────────────────────────────────────────────
/**
@@ -326,6 +372,39 @@ export class ShippingLineCreditsService {
return this.credits.outstandingFor(shippingLineCompanyId);
}
/**
* Back-office overview: outstanding totals across every line, or one line
* when an id is given.
*/
async summary(shippingLineCompanyId?: string) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
return this.credits.outstandingFor(shippingLineCompanyId);
}
/**
* The whole ledger across every shipping line, newest first — finance's
* landing list. Optionally narrowed to one line and/or one status.
*/
async listAll(
page = 1,
pageSize = 20,
status?: ShippingLineCreditStatus,
shippingLineCompanyId?: string,
) {
if (shippingLineCompanyId) {
await this.requireShippingLine(shippingLineCompanyId);
}
const [items, total] = await this.credits.findAllPaginated(
shippingLineCompanyId,
(page - 1) * pageSize,
pageSize,
status,
);
return { items, total, page, pageSize };
}
/** Full ledger for one line, newest first. */
async listCredits(
shippingLineCompanyId: string,
@@ -369,6 +448,287 @@ export class ShippingLineCreditsService {
};
}
// ── Credit invoices: list + makerchecker manual actions ─────────────────
/**
* Staff list of the invoices minted from credit batches, each with its line
* name and any undecided manual-action request attached — the data the
* back-office actions column renders from.
*/
async listCreditInvoices(
page = 1,
pageSize = 20,
status?: Freight.InvoiceStatus,
shippingLineCompanyId?: string,
) {
const [invoices, total] = await this.dataSource
.getRepository(Invoice)
.findAndCount({
where: {
source: Freight.InvoiceSource.ShippingLineCredit,
...(status ? { status } : {}),
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
},
order: { createdAt: "DESC" },
skip: (page - 1) * pageSize,
take: pageSize,
});
const lineIds = [
...new Set(
invoices
.map((inv) => inv.shippingLineCompanyId)
.filter((id): id is string => !!id),
),
];
const lines = lineIds.length
? await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(lineIds) } })
: [];
const nameById = new Map(lines.map((l) => [l.id, l.name]));
const pending = await this.approvals.findPendingByInvoiceIds(
invoices.map((inv) => inv.id),
);
const pendingByInvoice = new Map(pending.map((p) => [p.invoiceId, p]));
return {
items: invoices.map((inv) => ({
...inv,
shippingLineName: inv.shippingLineCompanyId
? (nameById.get(inv.shippingLineCompanyId) ?? null)
: null,
pendingAction: pendingByInvoice.get(inv.id) ?? null,
})),
total,
page,
pageSize,
};
}
/** Undecided requests for a batch of invoices — feeds any invoice list. */
async pendingInvoiceActions(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
// Bounded to a list page's worth of ids; anything larger is a misuse.
return this.approvals.findPendingByInvoiceIds(invoiceIds.slice(0, 100));
}
/**
* Finance raises a manual action on a credit invoice: record an offline
* payment (MARK_PAID) or void it (CANCEL). Nothing happens to the invoice
* yet — a chief with the matching approve permission decides it. One
* undecided request per invoice (backed by a partial unique index).
*/
async requestInvoiceAction(
invoiceId: string,
action: ShippingLineInvoiceActionType,
requestedBy: string,
reason: string,
paymentReference?: string,
): Promise<ShippingLineInvoiceApproval> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.source !== Freight.InvoiceSource.ShippingLineCredit) {
throw new BadRequestException(
"Manual actions here apply only to shipping-line credit invoices.",
);
}
// Fast feedback only — the billing service re-validates authoritatively
// (under lock) when the request is approved.
if (
action === ShippingLineInvoiceActionType.MarkPaid &&
invoice.status === Freight.InvoiceStatus.Paid
) {
throw new BadRequestException("Invoice is already paid.");
}
if (invoice.status === Freight.InvoiceStatus.Cancelled) {
throw new BadRequestException("Invoice is already cancelled.");
}
if (
action === ShippingLineInvoiceActionType.Cancel &&
Number(invoice.paidAmount) > 0
) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
const existing = await this.approvals.findPendingByInvoice(invoiceId);
if (existing) {
throw new BadRequestException(
`A ${existing.action} request is already awaiting decision on this invoice.`,
);
}
const approval = await this.approvals.create({
invoiceId,
action,
status: ShippingLineInvoiceActionStatus.Pending,
requestedBy,
reason,
paymentReference: paymentReference ?? null,
});
logCtx(
{
approvalId: approval.id,
invoiceId,
invoiceNumber: invoice.invoiceNumber,
action,
requestedBy,
},
{ path: "shippingLineCredit.invoiceAction.requested" },
);
return approval;
}
/**
* Decide a pending request. Gated purely by permission (the approve/reject
* grants on the controller routes) — a decider holding the grant may decide
* ANY pending request, their own included; that trade-off is deliberate.
*
* Approval executes the real action through the billing service AFTER the
* decision row commits — its settlement/cancellation events must fire from
* billing's own committed transaction (the credits listeners react to
* them). If billing then rejects the action, the decision is compensated
* back to PENDING so the request is not silently lost.
*/
async decideInvoiceAction(
approvalId: string,
decidedBy: string,
approve: boolean,
note?: string,
): Promise<ShippingLineInvoiceApproval> {
if (!approve && !note?.trim()) {
throw new BadRequestException(
"A note is required when rejecting a request.",
);
}
const decided = await this.dataSource.transaction(async (mg) => {
const approval = await this.approvals.findByIdForUpdate(mg, approvalId);
if (!approval) {
throw new NotFoundException(`Request ${approvalId} not found`);
}
if (approval.status !== ShippingLineInvoiceActionStatus.Pending) {
throw new BadRequestException(
`This request was already ${approval.status.toLowerCase()}.`,
);
}
const status = approve
? ShippingLineInvoiceActionStatus.Approved
: ShippingLineInvoiceActionStatus.Rejected;
await mg.update(
ShippingLineInvoiceApproval,
{ id: approvalId },
{
status,
decidedBy,
decidedAt: new Date(),
decisionNote: note ?? null,
},
);
return { ...approval, status, decidedBy, decisionNote: note ?? null };
});
if (!approve) {
logCtx(
{ approvalId, invoiceId: decided.invoiceId, decidedBy },
{ path: "shippingLineCredit.invoiceAction.rejected" },
);
return decided;
}
try {
if (decided.action === ShippingLineInvoiceActionType.MarkPaid) {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: decided.invoiceId } });
if (!invoice) {
throw new NotFoundException(`Invoice ${decided.invoiceId} not found`);
}
// Full settlement of the outstanding balance; billing emits
// `shipping_line_credit.invoice.paid`, which marks the credits PAID.
await this.billing.recordPayment(decided.invoiceId, {
amount: Number(invoice.balanceAmount ?? invoice.totalAmount),
method: "OFFLINE",
reference: decided.paymentReference ?? undefined,
metadata: {
approvalId: decided.id,
requestedBy: decided.requestedBy,
approvedBy: decidedBy,
},
});
} else {
// Billing emits `shipping_line_credit.invoice.cancelled`;
// onInvoiceCancelled releases the credits back to the unbilled pool.
await this.billing.cancelInvoice(decided.invoiceId);
}
} catch (err) {
// The action was refused (state changed since the request — e.g. the
// line paid through CBE in the meantime). Put the request back so it is
// not recorded as approved-but-unexecuted.
await this.approvals.update(approvalId, {
status: ShippingLineInvoiceActionStatus.Pending,
decidedBy: null,
decidedAt: null,
decisionNote: null,
});
throw err;
}
logCtx(
{
approvalId,
invoiceId: decided.invoiceId,
action: decided.action,
decidedBy,
},
{ path: "shippingLineCredit.invoiceAction.approved" },
);
return decided;
}
/**
* When a credit invoice is cancelled — through the approval flow or any
* other billing path — its BILLED credits return to the unbilled pool so
* the debt can be re-billed. The debt itself never disappears on invoice
* cancellation; only {@link cancelCredit} writes debt off.
*/
@OnEvent("shipping_line_credit.invoice.cancelled")
async onInvoiceCancelled(payload: InvoiceEventPayload): Promise<void> {
const result = await this.dataSource
.getRepository(ShippingLineCredit)
.update(
{
invoiceId: payload.invoiceId,
status: ShippingLineCreditStatus.Billed,
},
{
status: ShippingLineCreditStatus.Unbilled,
invoiceId: null,
billedAt: null,
},
);
logCtx(
{
invoiceId: payload.invoiceId,
invoiceNumber: payload.invoiceNumber,
creditsReleased: result.affected ?? 0,
},
{ path: "shippingLineCredit.invoiceCancelled.released" },
);
}
// ── Cancellation ───────────────────────────────────────────────────────────
/**

View File

@@ -0,0 +1,51 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EntityManager, In, Repository } from "typeorm";
import {
ShippingLineInvoiceApproval,
ShippingLineInvoiceActionStatus,
} from "./entities/shipping-line-invoice-approval.entity";
@Injectable()
export class ShippingLineInvoiceApprovalsRepository extends BaseRepository<ShippingLineInvoiceApproval> {
constructor(
@InjectRepository(ShippingLineInvoiceApproval)
private readonly approvals: Repository<ShippingLineInvoiceApproval>,
) {
super(approvals);
}
findPendingByInvoice(
invoiceId: string,
): Promise<ShippingLineInvoiceApproval | null> {
return this.approvals.findOne({
where: { invoiceId, status: ShippingLineInvoiceActionStatus.Pending },
});
}
/** Pending requests for a page of invoices — one query, no N+1. */
findPendingByInvoiceIds(
invoiceIds: string[],
): Promise<ShippingLineInvoiceApproval[]> {
if (!invoiceIds.length) return Promise.resolve([]);
return this.approvals.find({
where: {
invoiceId: In(invoiceIds),
status: ShippingLineInvoiceActionStatus.Pending,
},
});
}
/** Load one request inside the caller's transaction, locked for decision. */
findByIdForUpdate(
manager: EntityManager,
id: string,
): Promise<ShippingLineInvoiceApproval | null> {
return manager.getRepository(ShippingLineInvoiceApproval).findOne({
where: { id },
lock: { mode: "pessimistic_write" },
});
}
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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;