Merge pull request #1056 from Tria-plc/dev-to-staging

Syncing Staging
This commit is contained in:
Nathnael Wondisha
2026-08-01 10:31:17 +03:00
committed by GitHub
248 changed files with 16390 additions and 3520 deletions

View File

@@ -93,6 +93,9 @@ FAYDA_PRIVATE_KEY_BASE64=
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
# OAuth redirect_uri for the customer portal (its own origin — must also be
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code

View File

@@ -58,7 +58,7 @@
"@nestjs/typeorm": "^11.0.1",
"@nestjs/websockets": "^11.1.27",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.15.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",

View File

@@ -15,7 +15,7 @@ export interface FaydaJwk {
qi?: string;
}
export type FaydaPlatform = 'WEB' | 'MOBILE';
export type FaydaPlatform = 'WEB' | 'MOBILE' | 'PORTAL';
export interface FaydaConfig {
enabled: boolean;
@@ -25,8 +25,10 @@ export interface FaydaConfig {
userInfoEndpoint: string;
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
redirectUri: string;
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
/** OAuth redirect_uri sent to eSignet for WEB (backoffice) clients. Falls back to `redirectUri`. */
webRedirectUri: string;
/** OAuth redirect_uri sent to eSignet for the customer portal. Falls back to `webRedirectUri`. */
portalRedirectUri: string;
privateJwk: FaydaJwk;
scope: string;
acrValues: string;
@@ -77,6 +79,7 @@ export default registerAs('fayda', (): FaydaConfig => {
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
const portalRedirectUri = process.env.FAYDA_PORTAL_REDIRECT_URI || webRedirectUri;
if (!enabled) {
return {
enabled: false,
@@ -86,6 +89,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
scope,
acrValues,
@@ -117,6 +121,7 @@ export default registerAs('fayda', (): FaydaConfig => {
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
redirectUri,
webRedirectUri,
portalRedirectUri,
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
scope,
acrValues,

View File

@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
pricing,
rateSchedule,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff:
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
// Government contracts are generated at creation and signable at any
// time, in any order — no status gate, no customer-first sequencing.
canSignCustomer: booking.isGovernment
? !hasCustomer
: booking.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff: booking.isGovernment
? !hasStaff
: booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Two active "Sebeta" yards existed (code LEGACY_DEST label "Sebeta", and code
* SEBETA label "sebeta") — rates and routes pointed at one or the other, so a
* rate configured against one never matched a contract routed via the other.
* Merge them: keep the row all rates/distances/facilities reference
* (LEGACY_DEST), repoint every yard reference from the duplicate to it, retire
* the duplicate, and give the survivor the clean SEBETA code. Then make
* duplicate active yard labels/codes impossible at the DB level.
*/
export class MergeDuplicateSebetaYards3050000000000 implements MigrationInterface {
name = "MergeDuplicateSebetaYards3050000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
DECLARE
survivor uuid;
dupe uuid;
col record;
BEGIN
SELECT id INTO survivor FROM freight.yards
WHERE code = 'LEGACY_DEST' AND lower(trim(label)) = 'sebeta' AND deleted_at IS NULL;
SELECT id INTO dupe FROM freight.yards
WHERE code = 'SEBETA' AND deleted_at IS NULL;
IF survivor IS NULL OR dupe IS NULL OR survivor = dupe THEN
RETURN;
END IF;
-- Every yard-referencing column in the schema, so rows created between
-- authoring and running this migration are repointed too.
FOR col IN
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name <> 'yards'
AND (column_name LIKE '%yard_id%' OR column_name LIKE '%station_id%')
LOOP
EXECUTE format(
'UPDATE freight.%I SET %I = $1 WHERE %I = $2',
col.table_name, col.column_name, col.column_name
) USING survivor, dupe;
END LOOP;
UPDATE freight.yards
SET code = 'SEBETA@merged', label = 'sebeta@merged', deleted_at = now()
WHERE id = dupe;
UPDATE freight.yards SET code = 'SEBETA', label = 'Sebeta' WHERE id = survivor;
END $$;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_label_active"
ON freight.yards (lower(trim(label))) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yards_code_active"
ON freight.yards (lower(trim(code))) WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Data repair — not reversible. The uniqueness indexes are the new invariant.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_label_active"`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_yards_code_active"`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Export bookings get their own pay window, separately tunable from import:
* - global_rules.export_payment_window_minutes — global default for EXPORT
* (payment_window_minutes keeps governing IMPORT/DOMESTIC).
* - train_schedules.rule_payment_window_minutes — per-schedule override; until
* now the DTO accepted paymentWindowMinutes but only folded it into the
* reopen-delay sum, so the override never reached the actual pay window.
* - bookings.requested_train_schedule_id — the export train the customer picked
* at day-commit; pickExportSchedule honors it instead of earliest-first.
* - bookings.payment_reminder_sent_at — marks the one pre-deadline pay
* reminder so the 10s window tick doesn't re-send it.
*/
export class AddExportPaymentWindow3060000000000 implements MigrationInterface {
name = 'AddExportPaymentWindow3060000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.train_scheduling_global_rules ADD COLUMN IF NOT EXISTS export_payment_window_minutes int NOT NULL DEFAULT 60;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules ADD COLUMN IF NOT EXISTS rule_payment_window_minutes int;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS requested_train_schedule_id uuid;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS payment_reminder_sent_at timestamptz;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS payment_reminder_sent_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS requested_train_schedule_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS rule_payment_window_minutes;`,
);
await queryRunner.query(
`ALTER TABLE freight.train_scheduling_global_rules DROP COLUMN IF EXISTS export_payment_window_minutes;`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Break-bulk (PER_ITEM) bookings store their item count in
* cargo_total_weight_vgm, so the actual tonnage was never captured — wagon
* allocation divided an item COUNT by a tons capacity and under-allocated
* (400 machines ÷ 69T wagon read as 6 wagons instead of 12). New column holds
* the real total weight in tons for PER_ITEM cargo; null for PER_TON bulk and
* container bookings.
*/
export class AddBulkTotalWeightTons3070000000000 implements MigrationInterface {
name = 'AddBulkTotalWeightTons3070000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_total_weight_tons numeric(12,3);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_total_weight_tons;`,
);
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Several DCT (DORALEH) → GMP (KALITY) routes are missing the Dire Dawa stop
* in their milestone list. The corridor budget builds its per-leg edges from
* route_milestones, so on those routes a DCT→Dire Dawa or Dire Dawa→GMP
* booking cannot resolve its own leg and conservatively occupies the WHOLE
* route — per-leg wagon reuse (a wagon freed at Dire Dawa reloading for GMP)
* silently degrades to train-wide accounting.
*
* Insert the Dire Dawa milestone at sequence 2 on every active DORALEH→KALITY
* route with a stop list that lacks it, shifting later stops down. Matched by
* yard CODE so the repair is portable across environments. Idempotent: routes
* already carrying Dire Dawa are untouched.
*/
export class BackfillDireDawaMilestone3080000000000 implements MigrationInterface {
name = "BackfillDireDawaMilestone3080000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
DECLARE
dire uuid;
r record;
BEGIN
SELECT id INTO dire FROM freight.yards
WHERE code = 'DIRE_DAWA' AND deleted_at IS NULL;
IF dire IS NULL THEN
RETURN;
END IF;
FOR r IN
SELECT rt.id
FROM freight.routes rt
JOIN freight.yards o ON o.id = rt.origin_yard_id AND o.code = 'DORALEH'
JOIN freight.yards d ON d.id = rt.destination_yard_id AND d.code = 'KALITY'
WHERE rt.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM freight.route_milestones m
WHERE m.route_id = rt.id AND m.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM freight.route_milestones m
WHERE m.route_id = rt.id AND m.yard_id = dire
AND m.deleted_at IS NULL)
LOOP
-- Two-phase shift: uq_route_milestones_route_sequence isn't deferrable,
-- so a direct +1 UPDATE can collide mid-scan (seq 2 -> 3 while seq 3 still live).
-- Route through negative sequence_no first to avoid any interim collision.
-- Soft-deleted rows shift too: the constraint counts them, so a dead row
-- left at a target sequence would still collide.
UPDATE freight.route_milestones
SET sequence_no = -sequence_no
WHERE route_id = r.id AND sequence_no >= 2;
UPDATE freight.route_milestones
SET sequence_no = -sequence_no + 1
WHERE route_id = r.id AND sequence_no < 0;
INSERT INTO freight.route_milestones (route_id, yard_id, sequence_no)
VALUES (r.id, dire, 2);
END LOOP;
END $$;
`);
}
public async down(): Promise<void> {
// Data repair — not reversible.
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSavedSignatureStamp3090000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.saved_signatures
ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.saved_signatures
DROP COLUMN IF EXISTS stamp_file_id;
`);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* The draft/finalize phase is abolished: train schedules are created SCHEDULED
* and the Finalize button is gone from the backoffice. Promote every surviving
* DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there
* is no manual promotion path anymore). Idempotent; one-way — the original
* DRAFT set is not recorded, so down() cannot restore it.
*/
export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface {
name = "PromoteDraftSchedulesToScheduled3100000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE freight.train_schedules
SET status = 'SCHEDULED'
WHERE status = 'DRAFT'
AND deleted_at IS NULL`,
);
}
public async down(): Promise<void> {
// One-way data promotion — nothing to restore.
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Consist adjustments can now happen mid-route (train standing at a stop), so
* each history row records WHERE it happened. Nullable — rows written before
* this column simply have no yard.
*/
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
ADD COLUMN IF NOT EXISTS yard_id uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.schedule_wagon_adjustment_logs
DROP COLUMN IF EXISTS yard_id`,
);
}
}

View File

@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
expect(manager.update).not.toHaveBeenCalled();
});
});
describe("BillingService — CAC Bank (OTP debit)", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "USD",
balanceAmount: 500,
totalAmount: 500,
paymentId: "intent-1",
dueAt: null,
};
const build = (payment: Record<string, unknown>) => {
const repo = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
const initiate = jest.fn();
const { service } = build({ initiate });
await expect(
service.payInvoice("inv-1", { method: "CAC_BANK" }),
).rejects.toThrow(/payerAccount/);
expect(initiate).not.toHaveBeenCalled();
});
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
const handlePaymentEvent = jest.fn();
const { service } = build({
initiate: jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: {
intentId: "intent-1",
status: "REQUIRES_ACTION",
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
},
}),
handlePaymentEvent,
});
await service.payInvoice("inv-1", {
method: "CAC_BANK",
payerAccount: "77123456",
});
expect(handlePaymentEvent).not.toHaveBeenCalled();
});
it("confirms the OTP against the intent stamped on the invoice", async () => {
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
const { service } = build({ confirmOtp });
await service.confirmInvoiceOtp("inv-1", "123456");
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});

View File

@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
@@ -373,6 +373,34 @@ export class BillingService {
return this.payInvoice(id, opts);
}
/**
* Submit the CAC Bank OTP for one of the customer's own invoices
* (ownership-checked). Settlement of the invoice happens inside the payment
* service when the OTP succeeds.
*/
async confirmInvoiceOtpForUser(
id: string,
userId: string,
otp: string,
): Promise<IntentStatusDto> {
await this.findByIdForUser(id, userId);
return this.confirmInvoiceOtp(id, otp);
}
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
async confirmInvoiceOtp(
invoiceId: string,
otp: string,
): Promise<IntentStatusDto> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice?.paymentId) {
throw new NotFoundException("No payment to confirm for this invoice");
}
return this.payment.confirmOtp(invoice.paymentId, otp);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
@@ -1004,6 +1032,17 @@ export class BillingService {
* never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/
/**
* Settlement check before expiring a payable order (reconcile-before-expire):
* live-queries the gateway for any settled intent on the source order. Kept
* on billing so the domain never talks to the payment service directly.
*/
reconcilePayable(
sourceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
return this.payment.reconcileShipment(sourceId);
}
async payInvoice(
invoiceId: string,
opts: {
@@ -1024,11 +1063,39 @@ export class BillingService {
);
}
// A booking's PREPAID invoice is only payable inside its pay window —
// `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time).
// Blocking INITIATION here is what makes the deadline real: a payment
// STARTED before this gate but settling late is still honored by the
// expire-time gateway reconcile. Other invoice types keep dueAt display-only.
if (
invoice.source === Freight.InvoiceSource.Booking &&
invoice.type === "PREPAID" &&
invoice.dueAt &&
invoice.dueAt.getTime() <= Date.now()
) {
throw new BadRequestException(
"The payment window for this booking has closed — the reserved wagons " +
"were released. Please book again.",
);
}
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
@@ -1059,9 +1126,14 @@ export class BillingService {
.update({ id: invoice.id }, { paymentId: result.intentId });
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept for local demos only. NEVER for CBE_BILL —
// its bill must stay open until CBE actually settles it via /cbe/payment.
if (!result.immediateSuccess && opts.method !== "CBE_BILL") {
// billing must not simulate it. Kept commented for local demos only.
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
// code — so the demo shortcut must never fire for it.
if (
!result.immediateSuccess &&
result.response.clientAction?.type !== "COLLECT_OTP" &&
opts.method !== "CBE_BILL"
) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,

View File

@@ -1,5 +1,13 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";
/** OTP submitted for a COLLECT_OTP provider (CAC Bank). */
export class ConfirmOtpDto {
@ApiProperty({ description: "One-time password SMSed by the bank." })
@IsString()
@IsNotEmpty()
otp!: string;
}
/** Gateway options for paying an invoice from the customer portal. */
export class PayInvoiceDto {

View File

@@ -18,7 +18,7 @@ import {
} from "../../common/resolve-auth-user-id";
import { sendPdf } from "./billing.controller";
import { BillingService } from "./billing.service";
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
import { ConfirmOtpDto, PayInvoiceDto } from "./dto/pay-invoice.dto";
/**
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
@@ -96,4 +96,20 @@ export class PortalBillingController {
failureUrl: dto.failureUrl,
});
}
@Post("my-invoices/:id/confirm")
@ApiOperation({
summary: "Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices",
})
confirmOtp(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: ConfirmOtpDto,
) {
return this.billingService.confirmInvoiceOtpForUser(
id,
resolveAuthUserId(user),
dto.otp,
);
}
}

View File

@@ -112,15 +112,9 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
// from becoming ready — the document is (re)rendered lazily on view/download.
try {
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
// No eager PDF render here: streamContract re-renders the document on every
// view/download, so rendering now only adds a Chromium launch (seconds, or a
// 60s asset-load hang) inside the staff-accept request.
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -132,6 +126,31 @@ export class BookingContractService {
return updated!;
}
/**
* Government bookings skip the whole customer contract flow (approve →
* CONTRACT_READY → sign chain): their contract is stamped server-side at
* creation/expedite WITHOUT touching booking status — the booking is already
* PAID/allocatable and the contract can be signed at any time. Idempotent.
*/
async generateContractForGovernment(bookingId: string): Promise<void> {
const booking = await this.requireBooking(bookingId);
if (!booking.isGovernment || booking.contractGeneratedAt) return;
const templateKey = this.templateResolver.resolve(booking);
await this.bookingsRepository.update(bookingId, {
contractSummary: this.buildContractSummary(booking),
contractTemplateKey: templateKey,
contractGeneratedAt: new Date(),
} as never);
// Render the PDF eagerly but NEVER block creation on it — Chromium can take
// seconds (or hang on assets); the document re-renders on view/download.
void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch(
(err) =>
this.logger.warn(
`Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
),
);
}
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
@@ -152,8 +171,13 @@ export class BookingContractService {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
// Government contracts are order-free and status-free: either party may
// sign at any time (each once) — the booking is already expedited past the
// customer contract flow, so no status gate applies.
if (role === 'CUSTOMER') {
assertBookingStatus(booking, ['CONTRACT_READY']);
if (!booking.isGovernment) {
assertBookingStatus(booking, ['CONTRACT_READY']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
@@ -162,7 +186,9 @@ export class BookingContractService {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
if (!booking.isGovernment) {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
}
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
@@ -235,20 +261,30 @@ export class BookingContractService {
);
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
// Government bookings keep their operational status (PAID) — a signature
// must never pull them back into the customer workflow.
if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER';
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
if (!booking.isGovernment) {
updates.lockedAt = now;
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
}
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
// clearance bookings enter operations after the GL document gate.
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
// clearance bookings enter operations after the GL document gate. Government
// bookings are already in the pool from expedite — signing changes nothing.
if (
role === 'STAFF' &&
!booking.isGovernment &&
!clearanceCode &&
updated?.trainScheduleId
) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {

View File

@@ -16,6 +16,7 @@ import {
containersPerWagonForSize,
wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { bulkItemWagonsRequired } from '../train-scheduling/train-capacity.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
@@ -1186,6 +1187,10 @@ export class BookingPricingService {
...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
);
if (!(capacity > 0)) return null;
// Break-bulk (PER_ITEM): `tons` above is the item count; size by
// indivisible items instead of pretending the count is tonnage.
const byItems = bulkItemWagonsRequired(booking, capacity);
if (byItems > 0) return byItems;
return Math.max(1, Math.ceil(tons / capacity));
} catch {
return null;

View File

@@ -14,10 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
serviceType: { includesCustoms: false }, // no output set → only the input gate
};
// Input set has two required docs. Non-customs bookings resolve to the
// ONE_TIME self-clearance document set.
// Input set has two required docs. Non-customs bookings resolve to their
// own without-customs document set.
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', isRequired: true },
{ fileKey: 'packing_list', isRequired: true },
@@ -201,7 +201,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
*/
describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => {
const inputSetting = {
code: 'contract_clearance_selfclear_import_container',
code: 'clearance_import_container_without_customs',
fields: [
{ fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true },
{ fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true },

View File

@@ -26,6 +26,7 @@ describe('BookingTransitionService — operation review', () => {
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
};
const bookingBatchService = {
enqueueRouteDayProcessing: jest.fn(),
@@ -144,6 +145,7 @@ describe('BookingTransitionService — requestOperation export space gate', () =
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
assertNoUnpaidHold: jest.fn().mockResolvedValue(undefined),
checkDayCompatibilityForBooking: jest
.fn()
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),

View File

@@ -9,7 +9,10 @@ import {
} from "@nestjs/common";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import {
BookingBatchService,
type ExportTrainOption,
} from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -401,6 +404,31 @@ export class BookingTransitionService {
return fresh;
}
/**
* Customer cancels their own unpaid hold (SELECTED_FOR_BATCH): the wagons
* release immediately instead of tying up the train until the pay window
* lapses. Ends CANCELLED; the freed capacity tops up from the waiting list.
*/
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
if (booking.consolidationPartnerId) {
throw new BadRequestException(
"This booking shares a consolidated wagon with another booking — " +
"contact support to cancel it.",
);
}
await this.bookingsRepository.createReviewNote(
bookingId,
reason ?? "Customer cancelled before payment",
"REJECTION",
);
await this.bookingBatchService.cancelReservation(bookingId);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.cancelled(fresh, reason ?? "Cancelled before payment");
return fresh;
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -891,6 +919,7 @@ export class BookingTransitionService {
async requestOperation(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
@@ -898,6 +927,13 @@ export class BookingTransitionService {
"OPERATION_CHANGES_REQUESTED",
]);
// A company sitting on another unpaid hold commits nothing new — this is
// the moment export capacity locks, so the lock applies here too.
// Government bookings allocate without paying and are exempt.
if (!booking.isGovernment) {
await this.bookingsService.assertNoUnpaidHold(booking.companyId);
}
// A bare initiated instance (clearance-first flow) carries no cargo or
// price — it must go through the contract completion endpoint, which
// persists cargo, prices, invoices and only then lands here itself.
@@ -942,10 +978,18 @@ export class BookingTransitionService {
// largest bookable leftover ("reduce to N wagons or pick another day").
// Import/domestic bookings are batched + splittable, so they are NOT gated
// here — they get an advisory count below and the batch engine sizes them.
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
// 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;
const scheduledBooking = {
...booking,
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as Booking;
if (isExportTrain) {
// With export split ON the booking no longer has to ride ONE train whole:
// the largest fitting part is offered and the leftover rebooks on the next
@@ -958,9 +1002,14 @@ export class BookingTransitionService {
eatDay(date),
"EXPORT",
);
if (!fitting.length) {
const fitsRequest = requestedId
? fitting.some((f) => f.scheduleId === requestedId)
: fitting.length > 0;
if (!fitsRequest) {
throw new ConflictException(
"No export train on this day has space left — pick another shipment day.",
requestedId
? "The selected train has no space left — pick another train or day."
: "No export train on this day has space left — pick another shipment day.",
);
}
} else {
@@ -971,6 +1020,7 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
@@ -988,6 +1038,43 @@ export class BookingTransitionService {
* total covers the booking. `trainsForDay` is false when no departure carries
* the leg — the day is unbookable regardless of space.
*/
/**
* Export train picker data for a shipment day the customer is choosing:
* each export train on the booking's corridor with per-wagon-type free
* space. Export rail bookings only — nothing else picks a train.
*/
async exportTrainsForBooking(
bookingId: string,
scheduledDate: string,
overrides?: {
containerTypeIds?: string[];
containerSizes?: string[];
cargoTypeId?: string;
cargoTypeCode?: string;
wagons?: number;
},
): Promise<ExportTrainOption[]> {
const booking = await this.bookingsService.findById(bookingId);
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
}
if (
booking.tradeDirection !== "EXPORT" ||
isRoadService(booking.serviceType)
) {
throw new BadRequestException(
"Train selection is only available for export rail bookings",
);
}
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
return this.bookingBatchService.exportTrainOptionsForDay(
scheduledBooking,
eatDay(date),
overrides,
);
}
async dayAvailabilityForBooking(
bookingId: string,
scheduledDate: string,

View File

@@ -464,6 +464,26 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/carriage-acceptance-sheet')
@ApiOperation({
summary:
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
})
async carriageAcceptanceSheet(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@@ -751,10 +771,42 @@ export class BookingsController {
const booking = await this.transitionService.requestOperation(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/export-trains")
@ApiOperation({
summary:
"Export train picker: the day's export trains on the booking's corridor " +
"with per-wagon-type free space (export rail bookings only)",
})
async exportTrainsForBooking(
@Param("id", ParseUUIDPipe) id: string,
@Query("date") date: string,
// Bare contract instances carry no cargo yet — the completion form sends
// what the customer is entering so per-type space reflects THEIR cargo.
@Query("containerTypeIds") containerTypeIds?: string,
@Query("containerSizes") containerSizes?: string,
@Query("cargoTypeId") cargoTypeId?: string,
@Query("cargoTypeCode") cargoTypeCode?: string,
@Query("wagons") wagons?: string,
) {
const parsedWagons = Number(wagons);
return this.transitionService.exportTrainsForBooking(id, date, {
containerTypeIds: containerTypeIds
? containerTypeIds.split(",").filter(Boolean)
: undefined,
containerSizes: containerSizes
? containerSizes.split(",").filter(Boolean)
: undefined,
cargoTypeId: cargoTypeId || undefined,
cargoTypeCode: cargoTypeCode || undefined,
wagons: Number.isFinite(parsedWagons) && parsedWagons > 0 ? parsedWagons : undefined,
});
}
@Post(":id/operation/review")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
@@ -1271,6 +1323,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/cancel-hold")
@ApiOperation({
summary:
"Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); " +
"reserved wagons release immediately",
})
async cancelHold(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancelHold(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/consolidation")
@ApiOperation({ summary: "Request freight consolidation" })
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {

View File

@@ -23,6 +23,7 @@ import {
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
@@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
containerNumbers?: string[];
weightResult: ContainerWeightResult;
}>,
): Promise<BookingContainer[]> {
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const typeRepo = this.dataSource.getRepository(ContainerType);
const saved: BookingContainer[] = [];
@@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
isOverweight: item.weightResult.isOverweight,
overweightExcessTons: item.weightResult.overweightExcessTons,
});
saved.push(await containerRepo.save(row));
const savedRow = await containerRepo.save(row);
saved.push(savedRow);
// Physical container numbers, one unit row each (capped to the line
// quantity; blanks skipped). Optional — units can also be entered later.
const numbers = (item.containerNumbers ?? [])
.map((n) => n.trim())
.filter(Boolean)
.slice(0, item.quantity);
let sortOrder = 0;
for (const containerNumber of numbers) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: savedRow.id,
containerNumber,
vgmTons: item.vgmPerUnitTons,
sortOrder: sortOrder++,
}),
);
}
}
return saved;
@@ -1088,6 +1110,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
* the whole (route, day) pool rather than bookings pre-targeted to one train.
*/
day?: string;
/**
* The schedule's ordered route stops. When given, the corridor filter
* replaces the exact origin/destination match: any booking whose BOTH yards
* lie on the route qualifies (sub-corridor bookings like Dire→DCT on a
* GMT→Dire→DCT train — the caller still checks stop ORDER). Dateless
* DOMESTIC (intercity) bookings also join the pool: they ride any train on
* their corridor.
*/
corridorYardIds?: string[];
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
@@ -1111,8 +1142,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
// booking that still carries train_schedule_id).
if (options.day) {
// Dateless DOMESTIC (intercity) bookings ride any train on their corridor
// — no scheduled_date to match, so the day filter must not hide them.
qb.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
`(DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day
OR (booking.trade_direction = 'DOMESTIC' AND booking.scheduled_date IS NULL))`,
{ day: options.day },
);
} else if (options.trainScheduleId) {
@@ -1125,15 +1159,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
if (options.corridorYardIds?.length) {
qb.andWhere('booking.originYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
}).andWhere('booking.destinationYardId IN (:...corridorYardIds)', {
corridorYardIds: options.corridorYardIds,
});
} else {
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
});
}
}
if (options.schedulingStatus) {
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
@@ -1316,6 +1358,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Open unpaid holds (wagons reserved, pay window running) for a company. */
countUnpaidHoldsForCompany(companyId: string): Promise<number> {
return this.repository.count({
where: {
companyId,
status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']),
},
});
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository

View File

@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -68,6 +70,29 @@ export interface PaginatedBookings {
};
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
wagonType: string | null;
wagonNumber: string | null;
tareWeightTons: string | null;
equatedLength: string | null;
loadCapacityTons: string | null;
allocatedWeightTons: string | null;
trainNumber: string | null;
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
interface CarriageAcceptanceReceivedRow {
allocatedWeightTons: string | null;
containerNumbers: string | null;
}
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -103,6 +128,10 @@ export class BookingsService {
private readonly vehiclesService: VehiclesService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
@Inject(forwardRef(() => BookingContractService))
private readonly bookingContractService: BookingContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
) {}
async assignCustomerTruck(
@@ -202,6 +231,284 @@ export class BookingsService {
};
}
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType",
w.wagon_number AS "wagonNumber",
wt.tare_weight_tons AS "tareWeightTons",
tsw.length_meters AS "equatedLength",
tsw.capacity_tons AS "loadCapacityTons",
a.allocated_weight_tons AS "allocatedWeightTons",
s.train_number AS "trainNumber",
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.train_schedules s
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
ORDER BY tsw.sequence_no`,
[bookingId],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
// this sheet then — before the booking is put on a train. So a received
// export booking gets its sheet off the received cargo, wagon columns blank
// until the consist exists. Import keeps the allocation gate: nothing is
// accepted from the customer before the wagons carry it.
//
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to.
const pendingWagons = wagons.length === 0;
if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] =
booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
ORDER BY inv.created_at`,
[bookingId],
)
: [];
if (receivedLines.length === 0) {
throw new BadRequestException(
booking.tradeDirection === 'EXPORT'
? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet'
: 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
);
}
wagons = receivedLines.map((row, index) => ({
sequenceNo: index + 1,
wagonType: null,
wagonNumber: null,
tareWeightTons: null,
equatedLength: null,
loadCapacityTons: null,
allocatedWeightTons: row.allocatedWeightTons,
trainNumber: null,
departureAt: null,
marshalledAt: null,
arrivalAt: null,
containerNumbers: row.containerNumbers,
sealNumbers: null,
}));
}
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons });
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
label: 'carriage acceptance sheet',
fallback: (prepared) => buildTabularFallbackPdf(prepared),
});
return {
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
* remainder so the Price column always sums to the Total Amount on the sheet.
*/
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
const sum = weights.reduce((acc, w) => acc + w, 0);
const shares = weights.map((w) =>
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
);
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
return shares;
}
private buildCarriageAcceptanceSheetHtml(
booking: Booking,
wagons: CarriageAcceptanceWagonRow[],
{ pendingWagons }: { pendingWagons: boolean },
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
const money = (v: number) =>
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
const currency = booking.paymentCurrency ?? 'ETB';
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
const prices = this.splitAmountAcrossWagons(
totalAmount,
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
);
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
load: acc.load + (Number(w.allocatedWeightTons) || 0),
length: acc.length + (Number(w.equatedLength) || 0),
}),
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
const rows = wagons
.map(
(w, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(w.wagonType)}</td>
<td>${esc(w.wagonNumber)}</td>
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Carriage Acceptance Sheet</title>
<style>
@page { size: A4 landscape; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { font-size: 11px; color: #475569; margin-top: 4px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
tfoot td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
</style>
</head>
<body>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Carriage Acceptance Sheet</h1>
<div class="subtitle">Booking ${esc(booking.reference)}${esc(booking.tradeDirection)}</div>
</div>
<div class="meta">
Sheet No.
<strong>CAS-${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">SN</th>
<th>Type of Wagon</th>
<th>Wagon No.</th>
<th class="num">Tare Weight</th>
<th class="num">Equated Length</th>
<th class="num">Load Capacity</th>
<th>Arrival Station</th>
<th>Cargo Name</th>
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
<tr>
<td colspan="3">${
pendingWagons
? `Received lines: ${wagons.length} — wagons pending marshalling`
: `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})`
}</td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
<td class="num">${money(totalAmount)}</td>
</tr>
</tfoot>
</table>
<div class="notice">
${
pendingWagons
? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}.
Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.`
: `The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
Wagon identity, container and seal numbers must be verified against the physical consist
before the sheet is signed.`
}
</div>
<div class="signatures">
<div class="line">Signed by — EDR operations / date</div>
<div class="line">Signed by — customer or agent / date</div>
<div class="line">Signed by — marshalling yard / date</div>
</div>
</body>
</html>`;
}
/** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
@@ -602,6 +909,24 @@ export class BookingsService {
return result.booking;
}
/**
* A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved,
* pay window running) may not take more capacity until it pays or the hold
* dies: otherwise one customer can lock a train's wagons over and over
* without ever paying. EXPIRED / CANCELLED holds free the lock.
*/
async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -664,6 +989,10 @@ export class BookingsService {
companyId = company.id;
}
// Government bookings allocate without paying, so the unpaid-hold lock
// only applies to commercial companies.
if (!isGovernment) await this.assertNoUnpaidHold(companyId);
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
@@ -857,6 +1186,9 @@ export class BookingsService {
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
isHazardous: dto.isHazardous ?? false,
// Bulk reefer is the customer's toggle; container reefer is derived from
// the container type at pricing time, so the booking-level flag stays off
@@ -903,6 +1235,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
containerNumbers: c.containerNumbers,
weightResult: ruleResult.containerWeightResults[i],
})),
);
@@ -957,6 +1290,21 @@ export class BookingsService {
warnings.push(...consolidation.messages);
}
// Government bookings pass every customer step at creation: the server
// expedites them to PAID/Eligible, generates the contract (signable at any
// time) and queues priority placement. Best-effort — the booking row is
// already inserted, so a late failure must not 500 the whole create; the
// idempotent expedite endpoint remains the retry path.
if (isGovernment) {
try {
full = await this.governmentExpedite(booking.id, userId ?? 'system');
} catch (err) {
warnings.push(
`Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
);
}
}
return { booking: full, warnings };
}
@@ -1048,6 +1396,11 @@ export class BookingsService {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
freightType === 'BULK'
? (dto.bulkTotalWeightTons ?? existing.bulkTotalWeightTons ?? null)
: null,
// Booking-level reefer is only meaningful for bulk; container reefer is
// derived from the container type at pricing time.
isReefer:
@@ -1800,13 +2153,22 @@ export class BookingsService {
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
/**
* Expedite a government booking past every customer step: PAID + Eligible
* (no commercial hold, no payment), contract generated server-side (signable
* at any time), and the (route, day) fill kicked immediately so it grabs a
* seat on any open train — government-first, preempting commercial cargo if
* the day is full. Runs automatically at creation; the endpoint remains as a
* no-op-safe retry for older bookings.
*/
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
// Idempotent: create() already expedites — a repeat call changes nothing.
if (booking.status === 'PAID') return booking;
const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
@@ -1818,12 +2180,22 @@ export class BookingsService {
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingContractService.generateContractForGovernment(id);
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
// Priority placement: run the day-level fill now instead of waiting for a
// batch tick — the pool sorts government first and preempts if needed.
if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
eatDay(booking.scheduledDate),
);
}
return this.findById(id);
}

View File

@@ -0,0 +1,26 @@
import { BookingsService } from './bookings.service';
// The split is a pure helper on the prototype (never touches `this`), so it can be
// exercised without constructing the service and its dependency graph.
const split = (total: number, weights: number[]): number[] =>
(
BookingsService.prototype as unknown as {
splitAmountAcrossWagons(total: number, weights: number[]): number[];
}
).splitAmountAcrossWagons(total, weights);
describe('carriage acceptance sheet — price split', () => {
it('splits proportionally to allocated weight', () => {
expect(split(100, [30, 10])).toEqual([75, 25]);
});
it('splits equally when no weights are recorded', () => {
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
});
it('always sums back to the booking total despite rounding', () => {
const shares = split(100, [1, 1, 1]);
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
expect(shares).toEqual([33.33, 33.33, 33.34]);
});
});

View File

@@ -11,10 +11,8 @@ describe('clearance.util — clearanceSettingCode', () => {
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
'clearance_import_container_with_customs',
);
// Non-customs bookings self-clear with the same document set a ONE_TIME
// self-clear contract uses.
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
'contract_clearance_selfclear_import_container',
'clearance_import_container_without_customs',
);
});
@@ -23,7 +21,7 @@ describe('clearance.util — clearanceSettingCode', () => {
'clearance_export_bulk_with_customs',
);
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
'contract_clearance_selfclear_export_bulk',
'clearance_export_bulk_without_customs',
);
});

View File

@@ -39,12 +39,11 @@ export function clearanceSettingCode(
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
// Non-customs (Path A) bookings self-clear: the customer proves his own
// clearance with the SAME smaller document set a ONE_TIME self-clear
// contract uses (customs declaration, release permit, …) — not the
// GL-oriented booking sets.
// 4 import + 4 export cases (bulk/container × with/without customs) — each
// booking resolves to its own clearance_{op}_{freight}_{with|without}_customs
// set, independent of any contract-level clearance codes.
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
return `clearance_${op}_${freight}_without_customs`;
}
return `clearance_${op}_${freight}_with_customs`;
}

View File

@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
@ApiPropertyOptional()
stampImageUrl?: string | null;
}
export class ContractViewDto {

View File

@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
@ApiPropertyOptional({
description:
'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
type: [String],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
containerNumbers?: string[];
}
/**
@@ -325,6 +336,21 @@ export class CreateBookingDto {
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM — `cargoTotalWeightVgm` then carries the item count.
* Omit for PER_TON bulk and container freight.
*/
@ApiPropertyOptional({
minimum: 0,
description: 'Break-bulk (PER_ITEM) total weight in tons; cargoTotalWeightVgm holds the item count',
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value == null ? undefined : Number(value)))
bulkTotalWeightTons?: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -5,6 +5,7 @@ import {
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
Min,
MinLength,
@@ -93,6 +94,17 @@ export class RequestOperationDto {
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the specific train (schedule id) the customer picked ' +
'from GET /bookings/:id/export-trains. The reserve path locks onto this ' +
'train instead of earliest-first; 409 if it no longer fits. Ignored for ' +
'import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
}
export class OperationReviewDto {

View File

@@ -365,6 +365,15 @@ export class Booking extends BaseEntity {
@Column({ name: 'cargo_total_weight_vgm', type: 'numeric', precision: 12, scale: 3 })
cargoTotalWeightVgm!: number;
/**
* Break-bulk only: actual total cargo weight in tons when the bulk cargo
* type is PER_ITEM (`cargoTotalWeightVgm` then carries the item COUNT).
* Null for PER_TON bulk and all CONTAINER bookings. Wagon allocation uses
* weight ÷ count to size indivisible items per wagon.
*/
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@@ -499,6 +508,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/**
* EXPORT only: the specific train the customer picked at day-commit.
* pickExportSchedule reserves on this train (409 if it no longer fits)
* instead of falling back to earliest-departure-first. NULL = no preference.
*/
@Column({ name: 'requested_train_schedule_id', type: 'uuid', nullable: true })
requestedTrainScheduleId?: string | null;
/** Stamped when the one pre-deadline pay reminder went out (tick dedup). */
@Column({ name: 'payment_reminder_sent_at', type: 'timestamptz', nullable: true })
paymentReminderSentAt?: Date | null;
// ── Per-booking journey (segment corridor bookings) ────────────────────────
// A booking rides only its own origin→destination leg of the train's route,
// so dispatch/arrival are per-booking facts, not train facts. Clearance gates

View File

@@ -35,6 +35,10 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import {
CompanyIdentityStateDto,
CompleteIdentityVerificationDto,
} from "./dto/complete-identity-verification.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
@@ -188,9 +192,20 @@ export class CompaniesController {
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@CurrentUser() user: CurrentIamUser,
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
// Best-effort: a first-run onboarding draft may not exist yet, in which
// case there is no company to exclude and `tinTaken` checks every row —
// the correct behaviour for a brand-new lookup.
const companyId = await this.companiesService
.getCompanyInfoByUserId(user.id)
.then(({ company }) => company.id)
.catch(() => undefined);
const data = await this.companiesService.fetchETradeData(
dto.tin,
companyId,
);
return new ETradeResponseDto(data);
}
@@ -378,6 +393,32 @@ export class CompaniesController {
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
}
@Post("identity/fayda/complete")
@ApiOperation({
summary:
"Bind a completed Fayda verification to the company's owner or Power of Attorney. " +
"Start the flow with POST /fayda/verification/start (platform=PORTAL), then post the returned code+state here. " +
"The verified name, phone, email and address are written from the Fayda payload; on an approved company the change is staged for backoffice review.",
})
async completeIdentityVerification(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto);
}
@Delete("identity/fayda/poa")
@ApiOperation({
summary:
"Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together. " +
"Refused while the company holds a freight forwarder role, which cannot operate without a representative.",
})
async removePoaIdentity(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.removePoaIdentity(user.id);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -0,0 +1,458 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyNationality, CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* A person's identity is proved through Fayda: name, email, phone and address
* come from the verified payload, not typed. Fayda's userinfo carries no
* national ID number, so none is collected or derived here.
*
* Only the OWNER's credential varies by nationality:
* - Ethiopian company: the owner is verified through Fayda.
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
* supplies a typed passport number — required on its own, whether or not the
* owner also completes a (purely optional) Fayda verification.
*
* The PoA does not vary. A representative acts for the company inside Ethiopia
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
* is named, both nationalities must verify them, and their details come from
* the verified payload rather than the form.
*
* The owner is NOT the general manager — GM is a separate, plain typed role
* the portal offers a "same as owner" copy for, but it is never itself
* Fayda-verified or gated on.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
nationality: CompanyNationality;
verification: Record<string, unknown>;
}
const OWNER_VERIFIED = {
ownerFaydaSub: "owner-sub",
ownerFaydaVerifiedAt: "2026-07-01T00:00:00.000Z",
ownerName: "Abebe Bikila",
};
const POA_VERIFIED = {
poaFaydaSub: "poa-sub",
poaFaydaVerifiedAt: "2026-07-02T00:00:00.000Z",
poaName: "Tirunesh Dibaba",
poaEmail: "tirunesh@example.com",
poaPhone: "+251911000000",
};
const paper = () => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus: null,
});
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
nationality: CompanyNationality.Ethiopian,
verification: {
purpose: "VERIFY",
verified: true,
sub: "new-sub",
fullName: "Haile Gebrselassie",
email: "haile@example.com",
phoneNumber: "+251922000000",
address: "Addis Ababa",
birthdate: "1973-04-18",
gender: "Male",
},
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
nationality: ctx.nationality,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
if (patch.attributes)
ctx.attributes = patch.attributes as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () => null),
findLatestOpenByCompanyId: jest.fn(async () => null),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async () => null),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
verifayda: {
completeVerification: jest.fn(async () => ctx.verification),
},
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
deps.verifayda as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps, company };
}
describe("Fayda identity verification binds a person to the company", () => {
it("writes the verified identity", async () => {
const { service, ctx } = makeService();
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(ctx.attributes.ownerFaydaSub).toBe("new-sub");
expect(ctx.attributes.ownerName).toBe("Haile Gebrselassie");
expect(state.owner.verified).toBe(true);
});
it("fills every PoA detail from the payload, address included", async () => {
const { service, ctx } = makeService();
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(ctx.attributes.poaName).toBe("Haile Gebrselassie");
expect(ctx.attributes.poaEmail).toBe("haile@example.com");
expect(ctx.attributes.poaPhone).toBe("+251922000000");
expect(ctx.attributes.poaAddress).toBe("Addis Ababa");
});
it("verifies successfully even though Fayda returns no national ID number", async () => {
// Fayda's userinfo carries no FAN/FIN claim at all — this must be the
// normal, successful path, not an error.
const { service } = makeService({
verification: {
purpose: "VERIFY",
verified: true,
sub: "x",
fullName: "No Fan Here",
},
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(state.owner.verified).toBe(true);
});
it("refuses to make one identity both owner and PoA", async () => {
const { service } = makeService({
attributes: { ownerFaydaSub: "same-person" },
verification: {
purpose: "VERIFY",
verified: true,
sub: "same-person",
fullName: "Abebe Bikila",
},
});
await expect(
service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it("stages an owner re-verification for review on an approved company", async () => {
// The owner is the live company's identity proof, so re-verifying one is
// exactly what the backoffice review exists for: it must not rewrite the
// row directly.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
});
it("applies a PoA verification live on an approved company", async () => {
// The PoA is personnel the company names for itself — the delegation paper
// is what a reviewer actually judges — so it does not go to review.
const { service, ctx, deps } = makeService({
status: CompanyStatus.Active,
});
await service.completeIdentityVerification("user-1", {
subject: "poa",
code: "c",
state: "s",
});
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
});
it("refuses to rename a verified person by hand", async () => {
const { service } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.updateProfile("user-1", { poaName: "Someone Else" } as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("never locks or gates the general manager — it is not the verified subject", async () => {
// GM is a plain typed role; the portal offers a "same as owner" copy, but
// the backend must not treat it as identity-owned or require it verified.
const { service } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await expect(
service.updateProfile("user-1", {
generalManagerName: "Someone Else",
generalManagerEmail: "someone@example.com",
generalManagerPhone: "+251911223344",
} as never),
).resolves.toBeDefined();
});
});
describe("Ethiopian companies verify with Fayda; foreign companies verify identity by passport", () => {
// The company is applying for the forwarder role, so it must not already
// hold it — createCompanyProfileForUser short-circuits on an existing profile
// and would never reach the gate.
const applyingForFf = {
profileTypes: [ProfileType.importer],
attributes: { ...POA_VERIFIED },
files: [paper()],
};
it("blocks the forwarder role while the owner is unverified", async () => {
const { service } = makeService(applyingForFf);
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("blocks the forwarder role while the PoA is unverified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: {
...OWNER_VERIFIED,
poaName: "Tirunesh Dibaba",
poaEmail: "t@example.com",
poaPhone: "+251911000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once owner and PoA are both verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("never asks a foreign company for Fayda, verified or not", async () => {
const { service } = makeService({
nationality: CompanyNationality.Foreign,
});
const state = await service.completeIdentityVerification("user-1", {
subject: "owner",
code: "c",
state: "s",
});
// Still lets the owner verify — a foreign owner verifying is allowed, just
// never required — but the passport is the thing that actually gates it.
expect(state.owner.verified).toBe(true);
expect(state.faydaRequired).toBe(false);
expect(state.passportRequired).toBe(true);
});
it("blocks the forwarder role for a foreign company with no owner passport", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
...POA_VERIFIED,
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("still requires a Fayda-verified PoA from a foreign company", async () => {
// The owner's credential is nationality-specific; the representative's is
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
// typed foreign name is not a representative the platform can accept.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
ownerPassportNumber: "P1234567",
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
// Verifying is optional for a foreign owner, but it does not waive the
// passport requirement — the two are independent credentials.
const { service } = makeService({
profileTypes: [ProfileType.importer],
nationality: CompanyNationality.Foreign,
attributes: {
...OWNER_VERIFIED,
poaName: "Jean Dupont",
poaEmail: "jean@example.com",
poaPhone: "+33100000000",
},
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -20,6 +20,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { ETradeService } from "./services/etrade.service";
import { CompanyNotifierService } from "./company-notifier.service";
import { VerifaydaModule } from "../verifayda/verifayda.module";
@Module({
imports: [
@@ -38,6 +39,8 @@ import { CompanyNotifierService } from "./company-notifier.service";
// imports this module back for portal recipient targeting, hence forwardRef.
NotificationsModule,
forwardRef(() => NotificationInboxModule),
// Fayda identity verification for the company's owner and PoA.
VerifaydaModule,
],
controllers: [CompaniesController],
providers: [

View File

@@ -0,0 +1,245 @@
import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
* delegation paper on file. The rule used to live only in the onboarding
* wizard's completion check, so every other write that could break the pairing
* — saving PoA details, deleting the paper, picking up the forwarder role —
* went unguarded. These cover those writes.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
pendingSnapshot: Record<string, unknown> | null;
}
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
/**
* The forwarder role is gated on Fayda-verified identities as well as on the
* delegation paper. These tests are about the paper, so they run against a
* company whose identities are already verified — the identity rule itself is
* covered in companies.fayda-identity.spec.ts.
*/
const VERIFIED_IDENTITIES = {
ownerFaydaSub: "owner-sub",
poaFaydaSub: "poa-sub",
};
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
pendingSnapshot: null,
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
ctx.attributes = (patch.attributes ??
ctx.attributes) as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findLatestOpenByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async (id: string) =>
ctx.files.find((f) => f.id === id)
? {
...ctx.files.find((f) => f.id === id),
resource: "companies",
resourceId: "company-1",
name: "dars.pdf",
}
: null,
),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
);
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
// the PoA paths, so short-circuit it rather than mock the whole graph.
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps };
}
const paper = (reviewStatus: string | null = null) => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus,
});
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
it("rejects PoA details saved with no paper on file", async () => {
const { service } = makeService();
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("accepts PoA details once the paper is on file", async () => {
const { service } = makeService({ files: [paper()] });
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] });
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("leaves edits that don't touch the PoA alone", async () => {
// A company carrying legacy details must not be locked out of every other
// field until it produces a paper.
const { service } = makeService({ attributes: { ...POA }, files: [] });
await expect(
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
).resolves.toBeDefined();
});
it("refuses to remove the paper while the PoA is still named", async () => {
const { service } = makeService({
attributes: { ...POA },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("allows removing the paper once the PoA has been cleared", async () => {
const { service } = makeService({ attributes: {}, files: [paper()] });
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("judges the removal against a staged clear, not the live row", async () => {
// An Active company's edits are staged for review rather than written, so
// the live attributes still carry the PoA the customer just cleared.
const { service } = makeService({
status: CompanyStatus.Active,
attributes: { ...POA },
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("refuses the forwarder role to a company with no PoA", async () => {
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once PoA details and paper are both in place", async () => {
const { service } = makeService({
attributes: { ...POA, ...VERIFIED_IDENTITIES },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
});

View File

@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
.getMany();
}
async existsByTin(tin: string): Promise<boolean> {
const count = await this.repository.count({ where: { tin } as any });
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
const qb = this.repository
.createQueryBuilder('company')
.where('company.tin = :tin', { tin });
if (excludeCompanyId) {
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
}
const count = await qb.getCount();
return count > 0;
}

View File

@@ -0,0 +1,113 @@
import { CompaniesService } from "./companies.service";
import { CompanyType } from "./entities/company.entity";
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
/**
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
*
* Re-running role selection used to only ADD operational profiles, so a role
* the user unticked on the way back left its company_profile row behind — and
* every role-driven requirement (business license, forwarder PoA) is derived
* from those rows. startOnboarding now reconciles both directions.
*/
interface ExistingProfile {
id: string;
type: ProfileType;
status: ProfileStatus;
}
function makeService(existing: ExistingProfile[]) {
const companyProfilesRepo = {
findByCompanyId: jest.fn(async () => existing),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
softDelete: jest.fn(async () => undefined),
};
const companiesRepo = { update: jest.fn(async () => null) };
const profilesRepo = {
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: { id: "company-1" },
})),
};
const service = new CompaniesService(
companiesRepo as never,
companyProfilesRepo as never,
{} as never,
profilesRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
);
return { service, companyProfilesRepo };
}
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
const start = (service: CompaniesService, roles: ProfileType[]) =>
service.startOnboarding(identity as never, CompanyType.Customer, roles);
describe("re-running role selection reconciles the operational profiles", () => {
it("drops the profile for a role the user deselected", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-ff",
type: ProfileType.freightForwarder,
status: ProfileStatus.Pending,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
});
it("keeps an already-approved profile even when it is unticked", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
{
id: "p-exp",
type: ProfileType.exporter,
status: ProfileStatus.Active,
},
]);
await start(service, [ProfileType.importer]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
});
it("still adds a newly-picked role", async () => {
const { service, companyProfilesRepo } = makeService([
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
]);
await start(service, [ProfileType.importer, ProfileType.exporter]);
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ type: ProfileType.exporter }),
);
});
});

View File

@@ -0,0 +1,157 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsString, IsNotEmpty } from "class-validator";
import { Company, CompanyNationality } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
/**
* The two people a company is verified through — its owner and its Power of
* Attorney. "Owner" is not the same as the General Manager: a company's GM is
* a plain typed role (with a "same as owner" copy the portal offers), while
* the owner is the person this verification proves. They're very often the
* same human, which is exactly what the copy is for.
*/
export const IDENTITY_SUBJECTS = ["owner", "poa"] as const;
export type IdentitySubject = (typeof IDENTITY_SUBJECTS)[number];
export class CompleteIdentityVerificationDto {
@ApiProperty({
enum: IDENTITY_SUBJECTS,
description: "Which of the company's people this verification is for.",
})
@IsIn(IDENTITY_SUBJECTS)
subject!: IdentitySubject;
@ApiProperty({ description: "Authorization code from the Fayda redirect." })
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty({ description: "CSRF state from the Fayda redirect." })
@IsString()
@IsNotEmpty()
state!: string;
}
/** One person's verification state, as reported back to the portal. */
export class IdentityVerificationStateDto {
@ApiProperty() verified!: boolean;
@ApiProperty({ nullable: true }) name!: string | null;
@ApiProperty({ nullable: true }) phone!: string | null;
@ApiProperty({ nullable: true }) email!: string | null;
@ApiProperty({ nullable: true }) address!: string | null;
@ApiProperty({ nullable: true }) verifiedAt!: string | null;
@ApiProperty({ nullable: true }) birthdate!: string | null;
@ApiProperty({ nullable: true }) gender!: string | null;
}
export class OwnerIdentityStateDto extends IdentityVerificationStateDto {
@ApiProperty({
nullable: true,
description:
"Typed passport number — the foreign-company identity credential. Independent of Fayda: never written by a verification, and still required even if the owner also verifies.",
})
passportNumber!: string | null;
}
export class CompanyIdentityStateDto {
@ApiProperty({
description:
"True when Fayda verification of the owner (and PoA, once named) is mandatory — Ethiopian companies only.",
})
faydaRequired!: boolean;
@ApiProperty({
description:
"True when the owner's passport number is mandatory — foreign companies only. Independent of faydaRequired: a foreign owner may verify with Fayda too, but the passport is still required.",
})
passportRequired!: boolean;
@ApiProperty({ type: OwnerIdentityStateDto })
owner!: OwnerIdentityStateDto;
@ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"False while a mandatory requirement (Fayda for Ethiopian, passport for foreign) is still outstanding.",
})
complete!: boolean;
}
/** `attributes` key prefix per person. */
const PREFIX: Record<IdentitySubject, "owner" | "poa"> = {
owner: "owner",
poa: "poa",
};
/** company.attributes keys that together mean "a PoA was entered". */
const POA_KEYS = [
"poaName",
"poaPhone",
"poaEmail",
"poaLocation",
"poaAddress",
] as const;
function stateFor(
attrs: Record<string, unknown>,
subject: IdentitySubject,
): IdentityVerificationStateDto {
const p = PREFIX[subject];
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
return {
verified: Boolean(read(`${p}FaydaSub`)),
name: read(`${p}Name`),
phone: read(`${p}Phone`),
email: read(`${p}Email`),
address: read(`${p}Address`),
verifiedAt: read(`${p}FaydaVerifiedAt`),
birthdate: read(`${p}Birthdate`),
gender: read(`${p}Gender`),
};
}
/**
* Derive both people's verification state from the company row.
*
* Pure and shared: `CompaniesService` gates on it and `ProfileResponseDto`
* renders from it, so the settings page and the onboarding wizard can never
* disagree with the rule the API actually enforces.
*/
export function buildCompanyIdentityState(
company: Company,
): CompanyIdentityStateDto {
const attrs = company.attributes ?? {};
const read = (key: string) => (attrs[key] as string | undefined) ?? null;
// Fayda is an Ethiopian national ID — a foreign company's owner may not hold
// one, so a typed passport number is the mandatory credential there instead.
// The two are mutually exclusive by nationality but independently tracked,
// since a foreign owner verifying with Fayda doesn't waive the passport.
const foreign = company.nationality === CompanyNationality.Foreign;
const faydaRequired = !foreign;
const passportRequired = foreign;
const owner: OwnerIdentityStateDto = {
...stateFor(attrs, "owner"),
passportNumber: read("ownerPassportNumber"),
};
const poa = stateFor(attrs, "poa");
const poaDue =
(company.companyProfiles ?? []).some(
(p) => p.type === ProfileType.freightForwarder,
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
// Only the *owner's* credential is nationality-specific. A Power of Attorney
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
// proven with Fayda — a foreign company nominates a representative who holds
// one rather than typing a name nothing backs.
const ownerProven = faydaRequired
? owner.verified
: !passportRequired || Boolean(owner.passportNumber);
const complete = ownerProven && (!poaDue || poa.verified);
return { faydaRequired, passportRequired, owner, poa, complete };
}

View File

@@ -8,6 +8,8 @@
* truth the wizard uses to auto-finish.
*/
import { CompanyIdentityStateDto } from "./complete-identity-verification.dto";
export interface OnboardingInfoField {
key: string;
label: string;
@@ -40,11 +42,13 @@ export interface OnboardingPoaState {
required: boolean;
/** True once any PoA detail has been entered. */
provided: boolean;
/** True when the delegation letter is stored for the company. */
/** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean;
/** True when a reviewer sent the paper back for correction. */
delegationLetterFlagged: boolean;
/** PoA details still missing (only populated when `required`). */
missingFields: OnboardingInfoField[];
/** False while the PoA step still owes details or a delegation letter. */
/** False while the PoA step still owes details or an uncorrected paper. */
complete: boolean;
}
@@ -68,6 +72,13 @@ export class OnboardingRequirementsResponseDto {
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
poa: OnboardingPoaState;
/**
* Fayda verification state for the company's people. `required` is false for
* a foreign company, which is never gated on it — the portal renders the
* typed personnel forms in that case and the verify panels otherwise.
*/
identity: CompanyIdentityStateDto;
/** Overall setup progress across fields + documents + licenses. */
progress: { completed: number; total: number };
@@ -87,6 +98,7 @@ export class OnboardingRequirementsResponseDto {
this.documents = init.documents;
this.licenseProfiles = init.licenseProfiles;
this.poa = init.poa;
this.identity = init.identity;
this.progress = init.progress;
this.isComplete = init.isComplete;
this.onboardingCompleted = init.onboardingCompleted;

View File

@@ -1,3 +1,7 @@
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import {
@@ -52,6 +56,16 @@ export class ProfileResponseDto {
profileId: string;
/**
* Fayda verification state for the company's owner and PoA — not the general
* manager, which is a separate typed role. The settings tabs and the
* onboarding wizard render from `identity.faydaRequired` /
* `identity.passportRequired`: an Ethiopian company verifies the owner (and
* PoA) instead of typing their details; a foreign one requires a typed
* passport number instead.
*/
identity: CompanyIdentityStateDto;
/**
* Open profile-edit review, if any. `reviewStatus === "pending"` locks the
* settings page; `"rejected"` surfaces the note and prefills the (declined)
@@ -124,5 +138,6 @@ export class ProfileResponseDto {
: null;
this.reviewNote = openReview?.note ?? null;
this.pendingChanges = openReview?.snapshot ?? null;
this.identity = buildCompanyIdentityState(company);
}
}

View File

@@ -9,6 +9,10 @@ import {
ProfileLicenseFileView,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from './complete-identity-verification.dto';
export class ResponseCompanyProfileDto {
id: string;
@@ -69,6 +73,28 @@ export class ResponseCompanyDto {
* external profiles weren't loaded.
*/
onboardingCompleted?: boolean;
// eTrade-sourced registration record — populated by the onboarding TIN
// lookup, locked/read-only on the portal from the moment it's fetched.
licenceNumber?: string | null;
statusDescription?: string | null;
dateRegistered?: string | null;
renewedFrom?: string | null;
renewalDate?: string | null;
renewedTo?: string | null;
region?: string | null;
zone?: string | null;
woreda?: string | null;
kebele?: string | null;
houseNo?: string | null;
/**
* Owner/PoA Fayda verification state, shared with the portal
* (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces.
*/
identity: CompanyIdentityStateDto;
createdAt: Date;
updatedAt: Date;
@@ -95,6 +121,18 @@ export class ResponseCompanyDto {
? company.profiles.length === 0 ||
company.profiles.some((p) => p.onboardingCompleted)
: undefined;
this.licenceNumber = company.licenceNumber;
this.statusDescription = company.statusDescription;
this.dateRegistered = company.dateRegistered;
this.renewedFrom = company.renewedFrom;
this.renewalDate = company.renewalDate;
this.renewedTo = company.renewedTo;
this.region = company.region;
this.zone = company.zone;
this.woreda = company.woreda;
this.kebele = company.kebele;
this.houseNo = company.houseNo;
this.identity = buildCompanyIdentityState(company);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -44,10 +44,11 @@ export class UpdateProfileDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
// company's PoA (or its general manager), so it is derived from a completed
// Fayda verification rather than typed. The global validation pipe runs with
// forbidNonWhitelisted, so a client that still sends it gets a 400 telling it
// so — see CompaniesService.completeIdentityVerification.
@IsOptional()
@IsString()
@@ -110,6 +111,16 @@ export class UpdateProfileDto {
@IsString()
poaAddress?: string;
/**
* The owner's passport number — the identity credential for a foreign
* company, since Fayda is an Ethiopian national ID. Plain typed field, never
* written or locked by a Fayda verification: still required even if the
* owner also verifies.
*/
@IsOptional()
@IsString()
ownerPassportNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)

View File

@@ -109,6 +109,23 @@ export class ContractBookingService {
private readonly bookingTransitionService: BookingTransitionService,
) {}
/**
* Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths:
* a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new
* until it pays or the hold dies.
*/
private async assertNoUnpaidHold(companyId?: string | null): Promise<void> {
if (!companyId) return;
const holds =
await this.bookingsRepository.countUnpaidHoldsForCompany(companyId);
if (holds > 0) {
throw new ConflictException(
'You already have a booking waiting for payment. Pay it or cancel it ' +
'before making a new booking.',
);
}
}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
@@ -169,6 +186,8 @@ export class ContractBookingService {
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
await this.assertNoUnpaidHold(contract.companyId);
if (contract.contractKind === 'ONE_TIME') {
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
@@ -455,6 +474,7 @@ export class ContractBookingService {
);
}
}
await this.assertNoUnpaidHold(contract.companyId);
const route = await this.resolveRoute(contract, dto.contractRouteId);
@@ -846,6 +866,7 @@ export class ContractBookingService {
const completed = await this.bookingTransitionService.requestOperation(
booking.id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
);
return { booking: completed, warnings };
}

View File

@@ -26,30 +26,22 @@ function freightFor(freightType: string): Freight {
/**
* The customer-input clearance setting code, or null when no gate applies.
*
* - Path B (customs bundled): the customer uploads the documents GL needs to do
* the clearance work → `contract_clearance_{op}_{freight}`.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
* Contract-level IMPORT/EXPORT clearance has been removed — clearance is
* collected per booking instead (see bookings/clearance.util.ts), so this
* always returns null for IMPORT/EXPORT now.
*
* DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still
* collects the admin-configured intercity document set after both signatures
* (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract
* gate and collect the same set per booking instead.
* (ops-reviewed). GENERAL intercity contracts skip the contract gate and
* collect the same set per booking instead.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
_freightType: string,
_includesCustoms: boolean,
): string | null {
if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE;
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${op}_${freight}`;
return null;
}
/** The GL-output (customs output) setting code, keyed on op + freight. */

View File

@@ -42,6 +42,7 @@ describe('ContractsService duplicate guard', () => {
{} as never,
{} as never,
{} as never,
{ buildBreakdown: async () => ({ lineItems: [] }) } as never,
);
return (
service as unknown as {

View File

@@ -0,0 +1,122 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { ContractPricingService } from './contract-pricing.service';
import type { Contract } from './entities/contract.entity';
import type { Rate } from '../rule-engine/entities/rate.entity';
const CT20 = 'ct-20';
const CT40 = 'ct-40';
const DCT = 'yard-dct';
const SEBETA = 'yard-sebeta';
const GMP = 'yard-gmp';
const rate = (over: Partial<Rate>): Rate =>
({
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
containerTypeId: null,
cargoTypeId: null,
originYardId: DCT,
destinationYardId: SEBETA,
...over,
}) as Rate;
const contract = (over: Partial<Contract>): Contract =>
({
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
customsClearingEnabled: false,
isHazardous: false,
isReefer: false,
routes: [{ originYardId: DCT, destinationYardId: SEBETA, sortOrder: 0 }],
cargoScope: [{ containerSize: '20ft' }],
...over,
}) as Contract;
const service = (liveRates: Rate[]): ContractPricingService =>
new ContractPricingService(
{} as never,
{ findLiveRates: async () => liveRates } as never,
{
findAll: async () => ({
items: [
{ id: CT20, sizeFt: 20 },
{ id: CT40, sizeFt: 40 },
],
}),
} as never,
{ getRate: async () => 1 } as never,
);
describe('contract base freight is priced on the contract lane only', () => {
it('prices from the contract route, never another lane (CTR-2026-00065)', async () => {
const breakdown = await service([
// Same size, other lane — the leak that priced DCT → Sebeta at GMP rates.
rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
rate({ containerTypeId: CT20, rateValue: 750 }),
]).buildBreakdown(contract({}));
expect(breakdown.lineItems).toEqual([
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 750 }),
]);
});
it('blocks the contract when its lane has no container rate', async () => {
await expect(
service([
rate({ containerTypeId: CT20, destinationYardId: GMP, rateValue: 1690 }),
]).buildBreakdown(contract({})),
).rejects.toThrow(UnprocessableEntityException);
});
it('freezes the OVERWEIGHT_PER_TON surcharge on export contracts only', async () => {
const overweight = rate({
rateType: 'OVERWEIGHT_PER_TON',
trigger: 'OVERWEIGHT',
rateUnit: 'PER_TON',
rateValue: 25,
originYardId: null,
destinationYardId: null,
} as Partial<Rate>);
const exported = await service([
rate({ rateType: 'CONTAINER_EXPORT', containerTypeId: CT20, rateValue: 900 }),
overweight,
]).buildBreakdown(contract({ tradeDirection: 'EXPORT' }));
expect(exported.lineItems).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'OVERWEIGHT_PER_TON', unitPrice: 25 }),
]),
);
// Import derives overweight from the route's base freight — never frozen.
const imported = await service([
rate({ containerTypeId: CT20, rateValue: 750 }),
overweight,
]).buildBreakdown(contract({}));
expect(
imported.lineItems.some((li) => li.code === 'OVERWEIGHT_PER_TON'),
).toBe(false);
});
it('blocks bulk contracts too instead of borrowing an arbitrary rate', async () => {
const bulk = contract({ freightType: 'BULK', cargoScope: [] });
await expect(
service([
rate({
rateType: 'BULK_IMPORT',
rateUnit: 'PER_TON',
destinationYardId: GMP,
}),
]).buildBreakdown(bulk),
).rejects.toThrow(UnprocessableEntityException);
const priced = await service([
rate({ rateType: 'BULK_IMPORT', rateUnit: 'PER_TON', rateValue: 32 }),
]).buildBreakdown(bulk);
expect(priced.lineItems).toEqual([
expect.objectContaining({ code: 'BULK_FREIGHT', unitPrice: 32 }),
]);
});
});

View File

@@ -84,6 +84,26 @@ export class ContractPricingService {
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);
// Base rail freight is quoted per route (CK_rates_yard_scope) — only rates
// on the contract's own lane may price it. Matching without the yard filter
// is how a DCT → Sebeta contract froze DCT → GMP (Indode) prices, and the
// frozen snapshot then bills bookings that the route-scoped booking lookup
// would have hard-blocked (CTR-2026-00065).
// ponytail: multi-route contracts price the first lane (same as customs
// clearance below); per-lane pricing needs per-route breakdowns.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const onLane = route
? liveRates.filter(
(r) =>
r.rateType === baseType &&
r.currency === 'USD' &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: [];
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
@@ -97,17 +117,14 @@ export class ContractPricingService {
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
const rate =
liveRates.find(
(r) =>
r.rateType === baseType &&
r.currency === 'USD' &&
r.containerTypeId &&
matchedIds.has(r.containerTypeId),
) ??
liveRates.find(
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
onLane.find(
(r) => r.containerTypeId && matchedIds.has(r.containerTypeId),
) ?? onLane.find((r) => !r.containerTypeId);
if (!rate || Number(rate.rateValue) <= 0) {
throw new UnprocessableEntityException(
`No rail freight rate is configured for ${size} containers on this direction and route — the contract cannot be priced. Ask the rates team to set a live ${baseType} rate for this container type and origin → destination.`,
);
if (!rate) continue;
}
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
@@ -120,25 +137,26 @@ export class ContractPricingService {
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
// Freeze the rate for the contract's own commodity when one is configured
// — a per-item machinery rate and a per-ton wheat rate live side by side.
const bulkRates = liveRates.filter(
(r) => r.rateType === baseType && r.currency === 'USD',
);
// No arbitrary-rate fallback: another commodity's rate must never price
// this contract.
const bulkRate =
(cargoScope?.cargoTypeId
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
? onLane.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
: undefined) ??
bulkRates.find((r) => !r.cargoTypeId) ??
bulkRates[0] ??
onLane.find((r) => !r.cargoTypeId) ??
null;
if (bulkRate) {
lineItems.push({
code: 'BULK_FREIGHT',
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
unit: toContractUnit(bulkRate.rateUnit),
unitPrice: convert(Number(bulkRate.rateValue)),
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
});
if (!bulkRate || Number(bulkRate.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No bulk rail freight rate is configured for this cargo type on this direction and route — the contract cannot be priced. Ask the rates team to set a live rate for this commodity and origin → destination.',
);
}
lineItems.push({
code: 'BULK_FREIGHT',
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
unit: toContractUnit(bulkRate.rateUnit),
unitPrice: convert(Number(bulkRate.rateValue)),
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
});
}
// First / last mile trucking unit rates — shown when the contract carries
@@ -201,6 +219,26 @@ export class ContractPricingService {
});
}
}
// Overweight surcharge — EXPORT contracts freeze the OVERWEIGHT_PER_TON
// rate so booking pricing bills the contract's price on excess tons
// (frozenRateByCode wins over the live rate). Always included, no toggle:
// overweight is system-detected at booking, never customer-opted. IMPORT
// never reads this snapshot — its overweight price derives from the
// route's base container freight (see RuleEngineService).
if (contract.tradeDirection === 'EXPORT') {
const overweight = liveRates.find(
(r) => r.trigger === 'OVERWEIGHT' && r.currency === 'USD',
);
if (overweight && Number(overweight.rateValue) > 0) {
lineItems.push({
code: 'OVERWEIGHT_PER_TON',
label: 'Overweight surcharge (per excess ton)',
unit: toContractUnit(overweight.rateUnit),
unitPrice: convert(Number(overweight.rateValue)),
conditionalOn: 'is_overweight',
});
}
}
// Lashing / cargo securing — BULK only, shown when the contract's commodity
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
// contract's direction wins over the commodity-wide catch-all; billed at
@@ -241,9 +279,6 @@ export class ContractPricingService {
// one display line per contract size that has a configured rate. A size
// with no rate shows nothing here and hard-blocks at booking time.
// ponytail: bookings bill the live route rate, not a frozen snapshot.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const onLeg = route
? liveRates.filter(
(r) =>
@@ -291,9 +326,6 @@ export class ContractPricingService {
if (contract.customsClearingEnabled) {
// Strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const onLeg = route
? liveRates.filter(
(r) =>

View File

@@ -28,6 +28,7 @@ import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { diffContractFields } from './contract-document-diff.util';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ContractPricingService } from './contract-pricing.service';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -113,6 +114,7 @@ export class ContractsService {
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly documentHistory: ContractDocumentHistoryService,
private readonly pricingService: ContractPricingService,
) {}
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
@@ -331,6 +333,33 @@ export class ContractsService {
);
}
// Price the contract BEFORE anything persists: a lane with no configured
// rate 422s here and the wizard shows its blocking modal — with no orphan
// DRAFT row left behind for the customer to trip over on retry. The probe
// carries exactly the fields buildBreakdown prices from; relation-only
// niceties (cargoType labels) are absent, which only affects display
// lines, never the missing-rate gates.
await this.pricingService.buildBreakdown({
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
paymentCurrency: 'USD',
customsClearingEnabled: includesCustoms,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
equipmentReturn: dto.equipmentReturn ?? null,
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
routes: (dto.routes ?? []).map((r, i) => ({
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
sortOrder: r.sortOrder ?? i,
})),
cargoScope: (dto.cargoScope ?? []).map((c) => ({
containerSize: c.containerSize ?? null,
cargoTypeId: c.cargoTypeId ?? null,
})),
} as unknown as Contract);
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.

View File

@@ -175,6 +175,16 @@ export class CreateBookingUnderContractDto {
@IsDateString()
scheduledDate?: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the specific train (schedule id) picked from ' +
'GET /bookings/:id/export-trains for the shipment day. The reserve path ' +
'locks onto this train; 409 when it no longer fits. Ignored otherwise.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({
enum: SHIPMENT_EQUIPMENT_RETURNS,
description:

View File

@@ -15,6 +15,11 @@ import {
FILE_UPLOAD_SETTINGS_REPOSITORY,
IFileUploadSettingsRepository,
} from "./interfaces/file-upload-settings.repository.interface";
import {
COMPANY_ONBOARDING_CODE_PREFIX,
POA_DELEGATION_FILE_KEY,
poaDelegationField,
} from "./poa-delegation.constants";
@Injectable()
export class FileUploadSettingsService {
@@ -40,6 +45,22 @@ export class FileUploadSettingsService {
async getByCode(code: string): Promise<FileUploadSetting> {
const setting = await this.repository.findByCode(code);
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
return this.withPoaDelegationField(setting);
}
/**
* Company onboarding sets always carry the DARS delegation paper, whether or
* not anyone configured a row for it — see poa-delegation.constants.ts. Every
* consumer (the portal's PoA step, the onboarding gate) reads the set through
* here, so this is the single place the field can be guaranteed.
*/
private withPoaDelegationField(setting: FileUploadSetting): FileUploadSetting {
if (!setting.code.startsWith(COMPANY_ONBOARDING_CODE_PREFIX)) return setting;
const fields = setting.fields ?? [];
if (fields.some((f) => f.fileKey === POA_DELEGATION_FILE_KEY)) return setting;
const lastOrder = fields.reduce((max, f) => Math.max(max, f.displayOrder), 0);
setting.fields = [...fields, poaDelegationField(lastOrder + 1)];
return setting;
}

View File

@@ -0,0 +1,52 @@
import { FileUploadField } from "./entities/file-upload-field.entity";
/**
* The DARS delegation paper — the document that evidences a company's Power of
* Attorney (EDRFREIGHT-358).
*
* Every other onboarding document is admin-managed: the rows in
* `file_upload_fields` are edited from the backoffice file-settings editor and
* the seeder deliberately inserts none. This one is different — a company that
* names a PoA must produce a delegation paper authenticated by the Documents
* Authentication and Registration Service, and that is a legal requirement
* rather than a configuration choice. So the field is defined here in code and
* injected into the company onboarding sets on read: no row to forget to seed,
* and deleting one in the editor cannot silently switch the requirement off.
*/
/** FileRecord `code` (and upload field key) of the live delegation paper. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/** Code for a delegation paper staged in an open change request (not yet live). */
export const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
/** Customer-facing name of the document, used by the API and both web apps. */
export const POA_DELEGATION_LABEL = "DARS Delegation Paper";
/** Prefix of the setting codes the field is injected into. */
export const COMPANY_ONBOARDING_CODE_PREFIX = "company_onboarding_documents_";
const POA_DELEGATION_HELP =
"Delegation paper issued by the Documents Authentication and Registration " +
"Service (DARS) delegating the representative named above. Upload the " +
"authenticated copy — a plain letter is not accepted.";
/**
* The field descriptor. `isRequired` stays false because the paper is only due
* once a PoA has actually been named (or the company operates as a freight
* forwarder) — a rule that spans form fields as well as files, so it is
* enforced in CompaniesService rather than by this flag.
*/
export function poaDelegationField(displayOrder: number): FileUploadField {
return {
fileKey: POA_DELEGATION_FILE_KEY,
fileLabel: POA_DELEGATION_LABEL,
helpText: POA_DELEGATION_HELP,
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
maxSizeMb: 10,
displayOrder,
} as FileUploadField;
}

View File

@@ -0,0 +1,22 @@
import { Type } from 'class-transformer';
import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class TruckWarehouseGateTimeInput {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsDateString()
arrivedAt?: string | null;
@IsOptional()
@IsDateString()
departedAt?: string | null;
}
export class SetWarehouseGateTimesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => TruckWarehouseGateTimeInput)
trucks!: TruckWarehouseGateTimeInput[];
}

View File

@@ -24,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDetentionTimesDto } from './dto/set-detention-times.dto';
import { SetWarehouseGateTimesDto } from './dto/set-warehouse-gate-times.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
@@ -144,6 +145,18 @@ export class LastMileController {
return this.lastMileService.setDetentionTimes(id, dto.trucks);
}
@Post(':id/warehouse-gate-times')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@ApiOperation({
summary: 'Set each truck\'s warehouse gate arrival/departure times',
})
async setWarehouseGateTimes(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetWarehouseGateTimesDto,
) {
return this.lastMileService.setWarehouseGateTimes(id, dto.trucks);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -913,6 +913,41 @@ export class LastMileService {
return this.findById(id);
}
async setWarehouseGateTimes(
id: string,
trucks: Array<{
vehicleId: string;
arrivedAt?: string | null;
departedAt?: string | null;
}>,
): Promise<LastMile> {
await this.findById(id);
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
if (invoices.length) {
throw new BadRequestException(
'Warehouse gate times cannot be changed after the invoice is generated',
);
}
for (const t of trucks) {
const arrived = t.arrivedAt ? new Date(t.arrivedAt) : null;
const departed = t.departedAt ? new Date(t.departedAt) : null;
if (arrived && departed && departed.getTime() < arrived.getTime()) {
throw new BadRequestException(
'A truck cannot depart before it arrived — check the warehouse gate times',
);
}
await this.dataSource.manager.update(
LastMileVehicleAssignment,
{ lastMileId: id, vehicleId: t.vehicleId },
{ arrivedAt: arrived, departedAt: departed },
);
}
return this.findById(id);
}
async setDistances(
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -1,4 +1,9 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -31,6 +36,24 @@ export class PaymentClientService {
return this.call("POST", "/payments/initiate", request);
}
/**
* POST /payments/reconcile — settlement check for a domain order
* (reconcile-before-cancel). Live-queries every non-failed intent at the
* provider and registers any late capture found (flips it to SUCCEEDED and
* emits payment.succeeded). `unverifiable: true` = could not confirm
* "not paid" — the caller must NOT cancel/expire the order.
*/
async reconcileReference(
referenceType: PaymentReferenceType,
referenceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
return this.call("POST", "/payments/reconcile", {
service: PaymentService.FREIGHT,
referenceType,
referenceId,
});
}
/** GET /payments/intents?… — active intent by domain reference; null when none exists. */
async getIntentByReference(
referenceType: PaymentReferenceType,
@@ -49,6 +72,33 @@ export class PaymentClientService {
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
* re-enter the code.
*/
async confirmOtp(intentId: string, otp: string): Promise<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"POST",
`/payments/intents/${intentId}/confirm`,
{ otp },
);
} catch (err) {
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
if (err instanceof AxiosError && err.response?.status === 404) {
throw new BadRequestException("PaymentIntent not found");
}
if (err instanceof BadGatewayException) {
const detail = err.message.replace(/^Payment service error: /, "");
throw new BadRequestException(detail);
}
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {

View File

@@ -56,7 +56,13 @@ function rabbitMQImport(): DynamicModule[] {
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
// CAC Bank's initiate SMSes an OTP and routinely takes >10s, so the old
// 10s cap 502'd every CAC charge while the bank was still working —
// orphaning an intent the payer had already been texted about. Matches
// the passenger API's budget.
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
ConfigModule,
forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),

View File

@@ -0,0 +1,190 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { of, throwError } from "rxjs";
import { AxiosError, AxiosHeaders } from "axios";
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
import { PaymentClientService } from "./payment-client.service";
import { PaymentService } from "./payment.service";
/** Local intent projection row (the invoice's `paymentId` points at this). */
function localIntent(overrides: Record<string, unknown> = {}) {
return {
id: "intent-1",
refId: "booking-1",
referenceType: PaymentReferenceType.SHIPMENT,
status: "action-required",
method: "cac-bank",
merchantOrderId: "EDR_INV_1",
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
...overrides,
};
}
function makeRepo(rows: Record<string, unknown>[]) {
const store = [...rows];
return {
findOneBy: jest.fn((where: Record<string, unknown>) =>
Promise.resolve(
store.find((r) =>
Object.entries(where).every(([k, v]) => r[k] === v),
) ?? null,
),
),
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
const row = store.find((r) => r.id === where.id);
if (row) Object.assign(row, data);
return Promise.resolve(undefined);
}),
};
}
describe("PaymentService.confirmOtp", () => {
const build = (
client: Partial<PaymentClientService>,
rows = [localIntent()],
) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
client as never,
billing as never,
);
return { service, repo, billing };
};
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
const paidAt = "2026-07-31T10:00:00.000Z";
const { service, repo, billing } = build({
getIntentByReference: jest
.fn()
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
confirmOtp: jest.fn().mockResolvedValue({
intentId: "gw-1",
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "11709363209530624",
paidAt,
}),
});
const result = await service.confirmOtp("intent-1", "8280");
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({
status: "success",
transactionId: "11709363209530624",
}),
);
// Billing settles the invoice linked by this intent id.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"11709363209530624",
new Date(paidAt),
);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
});
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
const confirmOtp = jest
.fn()
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp,
});
await service.confirmOtp("intent-1", "8280");
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
});
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
const { service, repo, billing } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp: jest.fn().mockResolvedValue({
status: ProviderPaymentStatus.REQUIRES_ACTION,
failureMessage: "OTP confirmation failed",
}),
});
const result = await service.confirmOtp("intent-1", "0000");
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({ status: "action-required" }),
);
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("404s when the gateway has no active intent for the reference", async () => {
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue(null),
confirmOtp: jest.fn(),
});
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe("PaymentClientService.confirmOtp", () => {
const axiosErr = (status: number, message: string) =>
new AxiosError(
`Request failed with status code ${status}`,
undefined,
undefined,
undefined,
{
status,
statusText: "",
data: { message },
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
},
);
const build = (request: jest.Mock) =>
new PaymentClientService({ request } as never);
it("posts the OTP to the payment service intent-confirm route", async () => {
const request = jest
.fn()
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
const result = await build(request).confirmOtp("gw-1", "8280");
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
data: { otp: "8280" },
}),
);
expect(result.status).toBe("SUCCEEDED");
});
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
const request = jest
.fn()
.mockReturnValue(
throwError(() => axiosErr(400, "OTP confirmation failed")),
);
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
const request = jest
.fn()
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});

View File

@@ -193,6 +193,30 @@ export class PaymentService {
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
* has stored the intent id, avoiding a settle-before-correlation race.
*/
/**
* Reconcile-before-cancel: ask the payment service whether ANY intent for
* this shipment actually settled at the provider (bank/gateway). A late
* capture found there is registered as SUCCEEDED and emits payment.succeeded,
* which drives the normal paid flow. A network/provider error reports
* `unverifiable` — the caller must not expire the order on unknown.
*/
async reconcileShipment(
referenceId: string,
): Promise<{ paid: boolean; unverifiable: boolean }> {
try {
const result = await this.paymentClient.reconcileReference(
PaymentReferenceType.SHIPMENT,
referenceId,
);
return { paid: result.paid, unverifiable: result.unverifiable };
} catch (err) {
this.logger.warn(
`Reconcile for shipment ${referenceId} failed: ${(err as Error).message}`,
);
return { paid: false, unverifiable: true };
}
}
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
try {
const isCbeBill = input.method === ProviderMethod.CBE_BILL;
@@ -364,6 +388,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
* id (the invoice's `paymentId`) so the right invoice settles even when several
* invoices share a domain reference. The active gateway intent is looked up by
* reference, the OTP is forwarded, and the projection is refreshed. On success
* billing settles the linked invoice (idempotent — the outbox path converges too).
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
*/
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ id: intentId });
if (!local) throw new NotFoundException("PaymentIntent not found");
const snapshot = await this.paymentClient.getIntentByReference(
(local.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
local.refId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm");
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
notify: true,
});
} else {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(confirmed.status),
failerCode: confirmed.failureCode ?? undefined,
failureMessage: confirmed.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`

View File

@@ -6,6 +6,7 @@ import { Yard } from '../entities/yard.entity';
export interface IYardsRepository {
findById(id: string): Promise<Yard | null>;
findByCode(code: string): Promise<Yard | null>;
findByLabelInsensitive(label: string): Promise<Yard | null>;
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;

View File

@@ -22,6 +22,15 @@ export class YardsRepository implements IYardsRepository {
return this.repo.findOne({ where: { code } });
}
/** Case/whitespace-insensitive label lookup — backs the duplicate-yard guard. */
findByLabelInsensitive(label: string): Promise<Yard | null> {
return this.repo
.createQueryBuilder('yard')
.where('LOWER(TRIM(yard.label)) = LOWER(TRIM(:label))', { label })
.andWhere('yard.deleted_at IS NULL')
.getOne();
}
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]> {
return this.repo.find(options);
}

View File

@@ -0,0 +1,36 @@
import { ConflictException } from '@nestjs/common';
import { YardsService } from './yards.service';
import type { Yard } from '../entities/yard.entity';
const sebeta = { id: 'yard-1', code: 'LEGACY_DEST', label: 'Sebeta' } as Yard;
const service = (): YardsService =>
new YardsService(
{
findById: async (id: string) => ({ ...sebeta, id }),
findByCode: async () => null,
findByLabelInsensitive: async (label: string) =>
label.trim().toLowerCase() === 'sebeta' ? sebeta : null,
create: async (d: Partial<Yard>) => d as Yard,
update: async (_id: string, d: Partial<Yard>) => d as Yard,
} as never,
{ resolveCreateOrder: async () => 1 } as never,
);
describe('duplicate yard labels are rejected', () => {
it('blocks create even when the generated code differs (Sebeta vs LEGACY_DEST)', async () => {
await expect(
service().create({ label: ' sebeta ', country: 'ET' } as never),
).rejects.toThrow(ConflictException);
});
it('blocks renaming a yard onto another yard label, allows renaming itself', async () => {
await expect(
service().update('yard-2', { label: 'SEBETA' } as never),
).rejects.toThrow(ConflictException);
await expect(
service().update('yard-1', { label: 'Sebeta' } as never),
).resolves.toBeTruthy();
});
});

View File

@@ -31,6 +31,9 @@ export class YardsService {
/** Create a yard. */
async create(dto: CreateYardDto): Promise<Yard> {
// Label check first: the code check alone let "sebeta" in next to "Sebeta"
// when the existing yard's code didn't match its label (LEGACY_DEST).
await this.assertLabelAvailable(dto.label);
const code = generateCode(dto.label).slice(0, 40);
const existing = await this.repository.findByCode(code);
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
@@ -53,11 +56,20 @@ export class YardsService {
/** Update a yard. */
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
await this.findById(id);
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
const updated = await this.repository.update(id, dto);
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
return updated;
}
/** No two active yards may share a label (case/whitespace-insensitive). */
private async assertLabelAvailable(label: string, exceptId?: string): Promise<void> {
const dupe = await this.repository.findByLabelInsensitive(label);
if (dupe && dupe.id !== exceptId) {
throw new ConflictException(`A yard named "${dupe.label}" already exists`);
}
}
/**
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the

View File

@@ -139,6 +139,17 @@ export class SchedulingRescheduleService {
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
// Gov bookings may never be pushed off a train. Checked here (not only in
// unassignBooking) because the displacement loop below swallows unassign
// errors and force-detaches the booking anyway.
const govDisplaced = plan.displaced.filter((b) => b.isGovernment);
if (govDisplaced.length) {
throw new BadRequestException(
`Government bookings cannot be removed from a train: ${govDisplaced
.map((b) => b.reference)
.join(', ')}`,
);
}
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class SaveSignatureDto {
@ApiProperty()
@@ -13,6 +13,15 @@ export class SaveSignatureDto {
@IsString()
@MinLength(20)
signatureImageBase64!: string;
@ApiPropertyOptional({
description:
'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.',
})
@IsOptional()
@IsString()
@MinLength(20)
stampImageBase64?: string;
}
export class SavedSignatureDto {
@@ -21,4 +30,7 @@ export class SavedSignatureDto {
@ApiProperty({ nullable: true })
signatureImageUrl!: string | null;
@ApiProperty({ nullable: true })
stampImageUrl!: string | null;
}

View File

@@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity {
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'stamp_file_id' })
stampFile?: FileRecord | null;
}

View File

@@ -33,6 +33,7 @@ export class SignaturesController {
userId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
stampImageBase64: dto.stampImageBase64,
});
return this.signaturesService.getForUser(userId);
}

View File

@@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository<SavedSignature> {
findByUserId(userId: string): Promise<SavedSignature | null> {
return this.repository.findOne({
where: { userId } as never,
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
});
}

View File

@@ -13,6 +13,8 @@ export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
stampImageBase64?: string;
}
@Injectable()
@@ -31,15 +33,65 @@ export class SignaturesService {
return {
signerDisplayName: saved.signerDisplayName,
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url),
};
}
/** Insert or update the user's reusable signature, storing the image in MinIO. */
/** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
const file: Express.Multer.File = {
fieldname: 'signature',
originalname: `signature-${input.userId}.png`,
// Capture the previously referenced files so we can remove them only AFTER
// the saved_signatures row is repointed — deleting first would violate the
// FK constraint (saved_signatures.*_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const previousStampFileId = existing?.stampFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
});
const stampRecord = input.stampImageBase64
? await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'stamp',
file: this.toUploadFile('stamp', input.userId, input.stampImageBase64),
})
: null;
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
// Omitted stamp keeps whatever was saved before.
...(stampRecord ? { stampFileId: stampRecord.id } : {}),
});
const staleIds = [
previousFileId !== fileRecord.id ? previousFileId : null,
stampRecord && previousStampFileId !== stampRecord.id
? previousStampFileId
: null,
].filter((id): id is string => Boolean(id));
if (staleIds.length) {
await this.dataSource.getRepository(FileRecord).delete(staleIds);
}
return saved;
}
private toUploadFile(
kind: 'signature' | 'stamp',
userId: string,
base64: string,
): Express.Multer.File {
const buffer = this.decodeSignatureImage(base64);
return {
fieldname: kind,
originalname: `${kind}-${userId}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
@@ -49,33 +101,6 @@ export class SignaturesService {
filename: '',
path: '',
};
// Capture the previously referenced file so we can remove it only AFTER the
// saved_signatures row is repointed — deleting it first would violate the
// FK constraint (saved_signatures.signature_file_id -> files.id).
const existing = await this.signaturesRepository.findByUserId(input.userId);
const previousFileId = existing?.signatureFileId ?? null;
const fileRecord = await this.filesService.upload({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
const saved = await this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
if (previousFileId && previousFileId !== fileRecord.id) {
await this.dataSource
.getRepository(FileRecord)
.delete({ id: previousFileId });
}
return saved;
}
private async inlineImageUrl(

View File

@@ -1,15 +1,17 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
/**
* History row for a consist adjustment made from a schedule: staff coupled a
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
* e.g. trimming free wagons whose tare pushed gross weight over the
* locomotives' pull limit. Plain columns (no FK relations) so the history
* survives the wagon or train being deleted later.
* wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
* under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
* schedule's built train. `yardId` records WHERE it happened: the origin yard
* before departure, or the mid-route stop the train was standing at. Plain
* columns (no FK relations) so the history survives the wagon or train being
* deleted later.
*/
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
@Index(['trainScheduleId'])
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
adjustedByUserId!: string | null;
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
yardId!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
occurredAt!: Date;
}

View File

@@ -153,6 +153,14 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;
/**
* Per-schedule pay-window override (minutes). NULL = use the live global
* value for the schedule's direction. Unlike the other rule_* snapshots this
* is only written by an explicit staff override, never stamped at creation.
*/
@Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
rulePaymentWindowMinutes?: number | null;
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
ruleImportWindowLeadDays?: number | null;

View File

@@ -39,7 +39,11 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
physicalWagon: true,
allocations: {
booking: { company: true, bookingContainers: { containerType: true } },
containerItems: true,
// Both size sources loaded: the item's own container_type_id FK
// (always set for a manually-entered item) and the booking-line
// fallback via bookingContainer.containerType — the marshalling
// document's 40ft/20ft tally reads whichever is present.
containerItems: { containerType: true, bookingContainer: { containerType: true } },
},
},
},

View File

@@ -9,6 +9,9 @@
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
/** How long before the pay deadline the one reminder notification goes out. */
export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000;
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;

View File

@@ -109,6 +109,7 @@ describe('BookingBatchService — PAID reconcile', () => {
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
}),
};
@@ -150,6 +151,8 @@ describe('BookingBatchService — PAID reconcile', () => {
{
issuePayable: jest.fn().mockResolvedValue(null),
expirePayable: jest.fn().mockResolvedValue(undefined),
// Gateway reconcile-before-expire: default = verifiably unpaid.
reconcilePayable: jest.fn().mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
@@ -708,7 +711,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -731,7 +740,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -762,7 +777,13 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
{
issuePayable: jest.fn(),
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ emitPhase: jest.fn() } as never,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -1015,7 +1036,7 @@ describe('BookingBatchService — PAID reconcile', () => {
...(waiting as unknown as Record<string, unknown>),
status: 'SELECTED_FOR_BATCH',
trainScheduleId: exportScheduleId,
paymentDeadline: new Date(Date.now() - 1_000),
paymentDeadline: new Date(Date.now() - 60_000),
originYardId: 'yard-a',
destinationYardId: 'yard-b',
priorityScore: 0,
@@ -1307,10 +1328,9 @@ describe('BookingBatchService — built-train wagon capacity', () => {
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => {
// Export b→c holds every wagon of the border crossing: no further export
// can board anywhere (they all must ride that edge), so the window closes —
// while intercity keeps booking the free a→b leg through the per-leg budget.
it('is NOT full when the border edge is sold out but a home leg still has room', async () => {
// FULL is corridor-wide now: b→dj holds every wagon, but a→b is empty, so
// sub-corridor bookings can still sell that leg — the window stays open.
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
@@ -1324,6 +1344,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
});
it('is FULL once every leg of the corridor is sold out', async () => {
const { service } = buildService({
physicalWagons: 2,
routeStops: ['yard-a', 'yard-b', 'yard-dj'],
yardCountries: {
'yard-a': 'ETHIOPIA',
'yard-b': 'ETHIOPIA',
'yard-dj': 'DJIBOUTI',
},
reserved: [
reservedBooking('b1', { origin: 'yard-a', dest: 'yard-dj' }),
reservedBooking('b2', { origin: 'yard-a', dest: 'yard-dj' }),
],
});
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
});

View File

@@ -83,6 +83,11 @@ export class BookingJourneyService {
loadedByUserId: userId ?? null,
} as never);
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
// readiness warnings and workspace badges read loading_status, not loadedAt.
await manager
.getRepository(TrainScheduleBooking)
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' });
// The facility handed the cargo over — raise its GRN. No-ops for yards
// without a facility (import/export terminals), which keep their own flow.
await this.facilityHandling.recordHandling(manager, {
@@ -153,6 +158,14 @@ export class BookingJourneyService {
this.events.emit('booking.completed', { bookingId });
}
// The cargo is physically off the train at its own yard — mid-corridor or
// final. WarehouseInventoryService picks this up to create the warehouse
// record (import/intercity only; export already has one from receive).
this.events.emit('booking.unloadedAtYard', {
bookingId,
tradeDirection: booking.tradeDirection,
});
// Customer tracking: THIS booking arrived (train may still be rolling).
void this.completeMilestones(booking, [
...(booking.tradeDirection === 'IMPORT'
@@ -236,7 +249,10 @@ export class BookingJourneyService {
return {
scheduleId,
scheduleStatus: schedule.status,
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
// No checkpoint yet ⇒ the train is still at its origin, even just after
// dispatch — assertTrainAtYard allows origin loading in that state, so
// the UI position must agree or origin Load buttons grey out wrongly.
trainAtYardId: latest?.yardId ?? schedule.originStationId,
yards: [...byYard.values()],
};
}
@@ -311,11 +327,19 @@ export class BookingJourneyService {
RETURNING b.id, b.trade_direction`,
[schedule.id, schedule.destinationStationId, now],
);
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
for (const row of rows) {
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
if (row.trade_direction === 'DOMESTIC') {
this.events.emit('booking.completed', { bookingId: row.id });
}
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
// bookings caught by this fallback never left "awaiting unload".
this.events.emit('booking.unloadedAtYard', {
bookingId: row.id,
tradeDirection: row.trade_direction,
});
}
return rows.map((r) => r.id);
}

View File

@@ -137,6 +137,25 @@ export class BookingNotifierService {
});
}
/** One warning shortly before the pay window closes (sent once per hold). */
async payDeadlineApproaching(b: Booking, deadline: Date): Promise<void> {
const minutesLeft = Math.max(
1,
Math.round((deadline.getTime() - Date.now()) / 60_000),
);
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg =
`Payment reminder: about ${minutesLeft} minute${minutesLeft === 1 ? '' : 's'} left ` +
`to pay for booking ${b.reference ?? b.id}. Deadline: ${eat} EAT — ` +
`unpaid reservations are released and the wagons go back on sale.`;
await this.notifyContact(b, msg, 'PAY REMINDER');
// HIGH: minutes from losing the reserved wagons — must reach SMS/email.
this.inApp(b, 'Payment deadline approaching', msg, {
type: NotificationType.INVOICE_ISSUED,
priority: NotificationPriority.HIGH,
});
}
/**
* Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit
* this train. Paying accepts the split; letting the deadline pass keeps the

View File

@@ -72,7 +72,12 @@ describe('BookingSplitService — applySplit split marking', () => {
dataSource as never,
{} as never,
{} as never,
{ expirePayable: jest.fn() } as never,
{
expirePayable: jest.fn(),
reconcilePayable: jest
.fn()
.mockResolvedValue({ paid: false, unverifiable: false }),
} as never,
{ payNowPartial: jest.fn() } as never,
);
return { service, bookingRepo, contractRepo };

View File

@@ -18,7 +18,10 @@ export interface BookingWindowConfig {
windowDurationHours: number;
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
/** Pay window for IMPORT/DOMESTIC bookings (also part of the reopen gap). */
paymentWindowMinutes: number;
/** Pay window for EXPORT bookings — independent of the import value. */
exportPaymentWindowMinutes: number;
/**
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set
* (> 0), the effective booking cutoff is `departure this`, capping the first

View File

@@ -36,6 +36,7 @@ describe('BookingWindowService — window state machine', () => {
windowDurationHours: 1,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
};
const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>

View File

@@ -141,6 +141,13 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations();
// One pre-deadline pay reminder per hold (deduped via reminder stamp).
await this.bookingBatchService.sendPaymentReminders().catch((err) =>
this.logger.warn(
`Payment reminder sweep failed: ${(err as Error).message}`,
),
);
// Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1;
@@ -595,6 +602,8 @@ export class BookingWindowService implements OnModuleInit {
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
// Deadline is the line — expire() itself reconciles against the gateway
// before actually expiring, so a late in-window payment is still caught.
.andWhere('b.payment_deadline <= now()')
.andWhere('b.train_schedule_id IS NOT NULL')
.getRawMany<{ scheduleId: string }>();

View File

@@ -1,5 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
export class ConsistWagonSwitchDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
@IsUUID()
fromWagonId!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
@IsUUID()
toWagonId!: string;
}
export class AdjustScheduleConsistDto {
@ApiPropertyOptional({
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
@IsArray()
@IsUUID('all', { each: true })
removeWagonIds?: string[];
@ApiPropertyOptional({
type: [ConsistWagonSwitchDto],
description:
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ConsistWagonSwitchDto)
switches?: ConsistWagonSwitchDto[];
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class SwitchGovernmentBookingDto {
@ApiProperty({ format: 'uuid', description: 'Government booking to allocate onto the train' })
@IsUUID()
governmentBookingId!: string;
@ApiProperty({
format: 'uuid',
isArray: true,
description: 'Assigned commercial bookings to switch out in its place',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
removeBookingIds!: string[];
}

View File

@@ -61,13 +61,20 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60 })
@ApiPropertyOptional({ example: 60, description: 'IMPORT/DOMESTIC customer pay window, minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({ example: 60, description: 'EXPORT customer pay window, minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportPaymentWindowMinutes?: number;
// Booking-close offsets: minutes before departure the window shuts. The UI
// enters days/hours/minutes and converts to minutes. 0 or null clears the
// offset (close at departure). Nullable so it can be explicitly cleared.

View File

@@ -77,9 +77,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'doc_review_minutes', type: 'int', default: 30 })
docReviewMinutes!: number;
/** Pay window for IMPORT/DOMESTIC bookings (also feeds the window reopen delay). */
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
/** Pay window for EXPORT bookings — tunable independently of import. */
@Column({ name: 'export_payment_window_minutes', type: 'int', default: 60 })
exportPaymentWindowMinutes!: number;
/**
* Minutes before departure the IMPORT/DOMESTIC booking window shuts. When set,
* the window's close (first cycle and every reopen) is capped at

View File

@@ -1,4 +1,4 @@
import { bookingCargoTons } from './train-capacity.util';
import { bookingCargoTons, bulkItemWagonsRequired } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import {
@@ -51,8 +51,12 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: number): number {
if (booking.freightType === 'BULK') {
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
const capacity = bulkWagonCapacity && bulkWagonCapacity > 0 ? bulkWagonCapacity : 1;
// Break-bulk (PER_ITEM) sizes by indivisible items; `cargoTotalWeightVgm`
// holds the item count there, not tons.
const byItems = bulkItemWagonsRequired(booking, capacity);
if (byItems > 0) return byItems;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
}

View File

@@ -217,10 +217,20 @@ export class IntercityService {
// board a train that is full only on other legs.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!budget.fits(need, leg)) {
// Offer the part that DOES fit the leg (split-on-payment): customer is
// notified with a pay window for the fitting wagons; the remainder can
// be re-booked on a later train. Budget is consumed by the offer so the
// next booking in this pass sees the reduced room.
const offered = await this.bookingBatchService.offerIntercityPartial(
booking,
scheduleId,
budget,
);
rejected.push({
bookingId,
reason:
'Does not fit the remaining wagon/weight/length capacity for this train',
reason: offered
? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer'
: 'Does not fit the remaining wagon/weight/length capacity for this train',
});
continue;
}
@@ -320,8 +330,10 @@ export class IntercityService {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
// PAID = customer paid but staff have not placed it on a train yet
// (intercity allocation is manual) — it stays in the pool until they do.
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
@@ -372,8 +384,12 @@ export class IntercityService {
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
// awaiting manual placement) links straight onto the chosen train.
const readyStatuses = booking.isGovernment
? ['APPROVED']
: ['FULLY_EXECUTED', 'PAID'];
if (!readyStatuses.includes(booking.status)) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {

View File

@@ -1,6 +1,8 @@
import {
bookingCargoTons,
bookingGrossWeightTons,
bookingTrainLengthMeters,
bulkItemWagonsRequired,
consistUsage,
consistViolations,
deriveTrainCapacityFromLocomotive,
@@ -30,6 +32,66 @@ describe('train-capacity.util', () => {
cargoTons,
}));
describe('bulkItemWagonsRequired (break-bulk PER_ITEM)', () => {
// cargoTotalWeightVgm carries the ITEM COUNT for PER_ITEM cargo; the real
// tonnage rides in bulkTotalWeightTons.
const breakBulk = (quantity: number, weightTons: number) => ({
freightType: 'BULK',
cargoTotalWeightVgm: quantity,
bulkTotalWeightTons: weightTons,
});
it('floors items per wagon, then ceils wagons: 400 items / 800T on 69T wagons → 12', () => {
// 800/400 = 2T per item; floor(69/2) = 34 per wagon; ceil(400/34) = 12.
expect(bulkItemWagonsRequired(breakBulk(400, 800), 69)).toBe(12);
});
it('needs more wagons than raw tonnage suggests when the floor loses capacity', () => {
// 3 items × 40T on 69T wagons: by weight ceil(120/69) = 2, but only ONE
// whole 40T item fits a wagon → 3 wagons.
expect(bulkItemWagonsRequired(breakBulk(3, 120), 69)).toBe(3);
});
it('charges one wagon per item when a single item outweighs a wagon', () => {
expect(bulkItemWagonsRequired(breakBulk(2, 200), 69)).toBe(2);
});
it('returns 0 for PER_TON bulk (no stored weight) and container bookings', () => {
expect(
bulkItemWagonsRequired(
{ freightType: 'BULK', cargoTotalWeightVgm: 500, bulkTotalWeightTons: null },
69,
),
).toBe(0);
expect(
bulkItemWagonsRequired(
{ freightType: 'CONTAINER', cargoTotalWeightVgm: 100, bulkTotalWeightTons: 100 },
69,
),
).toBe(0);
});
it('returns 0 on zero/invalid capacity or amounts', () => {
expect(bulkItemWagonsRequired(breakBulk(400, 800), 0)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(0, 800), 69)).toBe(0);
expect(bulkItemWagonsRequired(breakBulk(400, 0), 69)).toBe(0);
});
});
describe('bookingCargoTons (break-bulk weight preference)', () => {
it('prefers bulkTotalWeightTons over the item-count VGM column', () => {
expect(
bookingCargoTons({ cargoTotalWeightVgm: 400, bulkTotalWeightTons: 800 }),
).toBe(800);
});
it('falls back to cargoTotalWeightVgm when no break-bulk weight is stored', () => {
expect(
bookingCargoTons({ cargoTotalWeightVgm: 500, bulkTotalWeightTons: null }),
).toBe(500);
});
});
describe('deriveTrainCapacityFromLocomotive', () => {
it('derives wagon slots from train length, not a fixed 53', () => {
const shortLoco = deriveTrainCapacityFromLocomotive(

View File

@@ -91,14 +91,21 @@ function num(value: unknown, fallback = 0): number {
* its container lines (quantity × VGM per unit). The portal's container flow
* stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the
* total alone made every such booking weigh only its tare.
*
* Break-bulk (PER_ITEM) bookings overload `cargoTotalWeightVgm` with the ITEM
* COUNT, so their real tonnage lives in `bulkTotalWeightTons` — prefer it, or
* a 400-item / 800T booking would "weigh" 400T against the pull limit.
*/
export function bookingCargoTons(booking: {
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
bookingContainers?: Array<{
quantity?: number | null;
vgmPerUnitTons?: number | string | null;
}> | null;
}): number {
const itemTons = num(booking.bulkTotalWeightTons);
if (itemTons > 0) return itemTons;
const total = num(booking.cargoTotalWeightVgm);
if (total > 0) return total;
return (booking.bookingContainers ?? []).reduce(
@@ -107,6 +114,32 @@ export function bookingCargoTons(booking: {
);
}
/**
* Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so
* floor how many whole items fit one wagon, then ceil the wagon count:
* 400 items / 800T on 69T wagons → 2T per item → 34 items per wagon → 12 wagons.
* Returns 0 when the booking is not item-counted (PER_TON bulk, containers) —
* callers then fall back to the pooled-tonnage math.
*/
export function bulkItemWagonsRequired(
booking: {
freightType?: string | null;
cargoTotalWeightVgm?: number | string | null;
bulkTotalWeightTons?: number | string | null;
},
capacityTons: number,
): number {
if (booking.freightType !== 'BULK' || !(capacityTons > 0)) return 0;
const quantity = num(booking.cargoTotalWeightVgm);
const totalWeightTons = num(booking.bulkTotalWeightTons);
if (!(quantity > 0) || !(totalWeightTons > 0)) return 0;
const perItemTons = totalWeightTons / quantity;
// ponytail: an item heavier than a whole wagon still charges 1 wagon per
// item; reject such bookings at creation time if the case turns real.
const itemsPerWagon = Math.max(1, Math.floor(capacityTons / perItemTons));
return Math.max(1, Math.ceil(quantity / itemsPerWagon));
}
/** Gross weight of one loaded wagon: it hauls itself plus its cargo. */
export function grossWagonWeightTons(slot: Pick<ConsistSlot, 'tareWeightTons' | 'cargoTons'>): number {
return num(slot.tareWeightTons) + num(slot.cargoTons);

View File

@@ -19,6 +19,7 @@ import {
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
@@ -194,6 +195,16 @@ export class TrainSchedulingController {
);
}
@Get("schedules/:id/history")
@TrainSchedulingView()
@ApiOperation({
summary:
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
})
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getScheduleHistory(id);
}
@Get("bookable-schedules")
// No staff guard: customers hit this while creating a booking to find OPEN
// same-route schedules. Do not attach train_scheduling permissions here.
@@ -408,6 +419,25 @@ export class TrainSchedulingController {
);
}
@Post("schedules/:id/switch-government-booking")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Switch out commercial bookings to allocate a government booking in their place",
})
switchGovernmentBooking(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SwitchGovernmentBookingDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainSchedulingService.switchGovernmentBooking(
id,
dto.governmentBookingId,
dto.removeBookingIds,
resolveAuthUserId(user),
);
}
@Get("schedules/:id/composition-removals")
@TrainSchedulingView()
@ApiOperation({ summary: "Get removal log for a schedule" })

View File

@@ -1351,4 +1351,88 @@ describe('TrainSchedulingService', () => {
).rejects.toThrow(/over its/);
});
});
describe('government booking protection', () => {
const scheduleId = 'sched-gov-1';
const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, {
isGovernment: true,
wagonsRequired: 10,
});
const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
wagonsRequired: 5,
});
const scheduleGraph = {
id: scheduleId,
status: 'DRAFT',
scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
trainSetId: 'ts-1',
trainSet: {
id: 'ts-1',
locomotive,
wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }],
},
scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }],
};
it('unassignBooking rejects a government booking', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking);
await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow(
/Government bookings cannot be removed/,
);
});
it('switchGovernmentBooking rejects a non-government incoming booking', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]);
await expect(
service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']),
).rejects.toThrow(/Only government bookings/);
});
it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WagonBookingAllocation) {
return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) };
}
return { find: jest.fn().mockResolvedValue([]) };
});
bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) =>
Promise.resolve(
ids.map((id) => (id === 'gov-1' ? govBooking : commercial)),
),
);
jest
.spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig')
.mockResolvedValue({} as never);
// Gov booking fits the plan (10 slots) but the switched-out booking only
// frees 5 wagons — the user-facing wagon rule must still reject it.
jest
.spyOn(
service as never as { validateBookingsForScheduling: () => unknown },
'validateBookingsForScheduling',
)
.mockResolvedValue({
valid: true,
violations: [],
warnings: [],
deferredBookings: [],
bookings: [govBooking],
wagonPlan: Array.from({ length: 10 }, (_, i) => ({
sequenceNo: i + 1,
allocations: [{ bookingId: 'gov-1' }],
})),
} as never);
await expect(
service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']),
).rejects.toThrow(/free only 5/);
});
});
});

View File

@@ -162,25 +162,36 @@ describe('applyWagonOrderReversal', () => {
expect(applyWagonOrderReversal(plan, null)).toBe(plan);
});
it('flips the order and renumbers sequenceNo 1..N when the flag is true', () => {
it('flips the position numbers when the flag is true', () => {
const reversed = applyWagonOrderReversal(plan, true);
// Physically-last wagon (was seq 3, wt-c) is now position 1.
expect(reversed.map((s) => s.wagonTypeId)).toEqual(['wt-c', 'wt-b', 'wt-a']);
expect(reversed.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
// Physically-last wagon (wt-c) is now position 1.
expect(reversed.map((s) => s.sequenceNo)).toEqual([3, 2, 1]);
});
it('keeps each booking with its own wagon — only the position changes', () => {
const reversed = applyWagonOrderReversal(plan, true);
// The booking that was in the last wagon now sits at sequenceNo 1.
expect(reversed[0].sequenceNo).toBe(1);
const atPosition1 = reversed.find((s) => s.sequenceNo === 1);
expect(
(reversed[0].allocations as { bookingId: string }[])[0].bookingId,
(atPosition1?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-C');
const atPosition3 = reversed.find((s) => s.sequenceNo === 3);
expect(
(reversed[2].allocations as { bookingId: string }[])[0].bookingId,
(atPosition3?.allocations as { bookingId: string }[])[0].bookingId,
).toBe('BKG-A');
});
// The regression that emptied every reversed train's container items: the
// placement generators pair unit k (booking order) with slot k of this array,
// and persistAllocationsAndLoads matches that sequenceNo against the
// allocation's booking. Array order must stay packing order.
it('keeps array order aligned with booking order so placements still match', () => {
const reversed = applyWagonOrderReversal(plan, true);
expect(
reversed.map((s) => (s.allocations as { bookingId: string }[])[0].bookingId),
).toEqual(['BKG-A', 'BKG-B', 'BKG-C']);
});
it('does not mutate the input plan', () => {
applyWagonOrderReversal(plan, true);
expect(plan.map((s) => s.sequenceNo)).toEqual([1, 2, 3]);
@@ -198,7 +209,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
new Map(entries);
it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => {
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only.
// 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only:
// the intercity 20ft alights where the export 20ft boards, so both share
// the single physical wagon (cross-leg TEU sharing).
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
@@ -222,16 +235,19 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
'EXPORT-1',
'INTERCITY-1',
]);
// Two slots planned, but both drawn from the single physical wagon.
expect(result.plan).toHaveLength(2);
expect(result.plan).toHaveLength(1);
});
it('still defers when the legs overlap and stock is exhausted', () => {
it('still defers when the wagon has no per-edge TEU room and stock is exhausted', () => {
// Export is a 40ft (2 TEU) riding the whole corridor — no edge has room
// for the intercity 20ft, and there is no second wagon to open.
const fortyFooter = containerBooking('EXPORT-1', 1, 1);
fortyFooter.bookingContainers![0]!.containerType = {
code: '40GP',
sizeFt: 40,
} as never;
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
containerBooking('INTERCITY-1', 1, 1),
],
bookings: [fortyFooter, containerBooking('INTERCITY-1', 1, 1)],
allowed,
stock: {
mode: 'TRAIN',
@@ -239,7 +255,6 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
codesByTypeId: new Map([[nw6.id, nw6.code]]),
},
legs: legs([
// Both ride edge 0 — they compete for the one wagon.
['EXPORT-1', { from: 0, to: 2 }],
['INTERCITY-1', { from: 0, to: 1 }],
]),
@@ -252,9 +267,9 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left');
});
it('never packs bookings with different legs into the same wagon slot', () => {
// Two 20ft units with room to share one wagon by TEU — but disjoint legs
// must open separate slots (each with its own leg), not one mixed slot.
it('packs disjoint-leg 20fts onto one wagon instead of appending a second', () => {
// Two 20ft units, two wagons in stock — cross-leg TEU sharing still fills
// the open wagon (span grows to the union) rather than opening wagon #2.
const result = planWagonsWithStock({
bookings: [
containerBooking('EXPORT-1', 1, 1),
@@ -273,11 +288,12 @@ describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () =>
edgeCount: 2,
});
expect(result.plan).toHaveLength(2);
const bookingsPerSlot = result.plan.map((s) =>
[...new Set(s.allocations.map((a) => a.bookingId))].sort(),
);
expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]);
expect(result.deferred).toHaveLength(0);
expect(result.plan).toHaveLength(1);
const bookingsInSlot = [
...new Set(result.plan[0]!.allocations.map((a) => a.bookingId)),
].sort();
expect(bookingsInSlot).toEqual(['EXPORT-1', 'INTERCITY-1']);
});
it('behaves exactly like the whole-route planner when no legs are given', () => {

View File

@@ -53,18 +53,25 @@ export type FlexPlanResult = {
type OpenSlot = {
slot: WagonPlanSlot;
teuUsed: number;
/**
* TEU occupied PER CORRIDOR EDGE. Containers on different legs share the
* same physical wagon as long as no single edge exceeds the wagon's TEU
* geometry — an intercity 20ft alighting at Adama frees its slot for a 20ft
* boarding there, and two overlapping-leg 20fts coexist while both ride.
*/
teuPerEdge: number[];
kind: SlotLoadType;
/** Kind purity: a bulk wagon carries ONE cargo type at a time. */
cargoTypeId: string | null;
freeCapacityTons: number;
/**
* Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only
* share a slot when their legs are identical — mixing corridors in one slot
* would degrade it to a whole-route slot (see stampSlotLegs) and silently
* re-occupy edges the cargo never rides.
* Leg of the FIRST booking placed (`"from-to"` stop indexes). Containers
* prefer a same-leg slot but may extend onto a different-leg one (span
* grows to the union); bulk still shares only on an identical leg.
*/
legKey: string;
/** Contiguous stop-index span this wagon physically rides (union of its cargo legs). */
covered: { from: number; to: number };
};
/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */
@@ -227,16 +234,54 @@ export function planWagonsWithStock(params: {
for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1;
const open: OpenSlot = {
slot: slotFromWagonType(chosen, kind),
teuUsed: 0,
teuPerEdge: new Array<number>(edgeCount).fill(0),
kind,
cargoTypeId,
freeCapacityTons: Number(chosen.capacityTons),
legKey: legKeyOf(leg),
covered: { ...leg },
};
openSlots.push(open);
return open;
};
/** TEU room on every edge of the unit's leg. */
const teuFits = (open: OpenSlot, leg: BookingLeg, teu: number): boolean => {
for (let e = leg.from; e < leg.to; e += 1) {
if ((open.teuPerEdge[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) return false;
}
return true;
};
/**
* Whether the slot's ridden span can grow to include this leg: every NEW
* edge (outside the current span) must still have a physical wagon of the
* slot's type spare — extending the span puts this wagon on those edges.
*/
const canExtendSpan = (open: OpenSlot, leg: BookingLeg): boolean => {
const total = stock.remainingByTypeId.get(open.slot.wagonTypeId) ?? 0;
const row = usedPerEdge.get(open.slot.wagonTypeId);
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
if (e >= open.covered.from && e < open.covered.to) continue;
if (total - (row?.[e] ?? 0) <= 0) return false;
}
return true;
};
/** Grow the slot's span onto the leg's new edges, consuming stock there. */
const extendSpan = (open: OpenSlot, leg: BookingLeg): void => {
const row = usedRow(open.slot.wagonTypeId);
const from = Math.min(open.covered.from, leg.from);
const to = Math.max(open.covered.to, leg.to);
for (let e = from; e < to; e += 1) {
if (e >= open.covered.from && e < open.covered.to) continue;
row[e] = (row[e] ?? 0) + 1;
}
open.covered = { from, to };
};
const tryPlaceBooking = (booking: Booking): PlacementProblem | null => {
const leg = legFor(booking);
const legKey = legKeyOf(leg);
@@ -260,17 +305,23 @@ export function planWagonsWithStock(params: {
}
const allowedIds = new Set(candidates.map((wt) => wt.id));
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
let target = openSlots.find(
(open) =>
open.kind === 'CONTAINER' &&
open.legKey === legKey &&
allowedIds.has(open.slot.wagonTypeId) &&
open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON,
);
const fitsSlot = (open: OpenSlot): boolean =>
open.kind === 'CONTAINER' &&
allowedIds.has(open.slot.wagonTypeId) &&
teuFits(open, leg, teu) &&
canExtendSpan(open, leg);
// Same-leg slots first (keeps legacy packing byte-identical), then any
// open wagon with per-edge TEU room — an intercity 20ft rides an
// export wagon's spare slot instead of appending a new wagon.
let target =
openSlots.find((open) => open.legKey === legKey && fitsSlot(open)) ??
openSlots.find(fitsSlot);
if (!target) {
const openedSlot = openSlot(candidates, 'CONTAINER', null, leg);
if ('message' in openedSlot) return openedSlot;
target = openedSlot;
} else {
extendSpan(target, leg);
}
addAllocation(
target.slot,
@@ -279,7 +330,9 @@ export function planWagonsWithStock(params: {
unit.grossWeightTons,
AllocationLoadType.Container,
);
target.teuUsed += teu;
for (let e = leg.from; e < leg.to; e += 1) {
target.teuPerEdge[e] = (target.teuPerEdge[e] ?? 0) + teu;
}
}
return null;
}
@@ -343,7 +396,8 @@ export function planWagonsWithStock(params: {
);
const slotCountSnapshot = openSlots.length;
const slotStateSnapshot = openSlots.map((open) => ({
teuUsed: open.teuUsed,
teuPerEdge: [...open.teuPerEdge],
covered: { ...open.covered },
freeCapacityTons: open.freeCapacityTons,
assignedWeightTons: open.slot.assignedWeightTons,
allocationCount: open.slot.allocations.length,
@@ -363,7 +417,8 @@ export function planWagonsWithStock(params: {
openSlots.forEach((open, index) => {
const snap = slotStateSnapshot[index];
if (!snap) return;
open.teuUsed = snap.teuUsed;
open.teuPerEdge = [...snap.teuPerEdge];
open.covered = { ...snap.covered };
open.freeCapacityTons = snap.freeCapacityTons;
open.slot.assignedWeightTons = snap.assignedWeightTons;
open.slot.allocations.length = snap.allocationCount;
@@ -415,15 +470,22 @@ export function planWagonsWithStock(params: {
* sequenceNos, the snapshot re-sorts by them, and the board/allocation views all
* read them — so the stored train order and the schedule order stay identical,
* just reversed. A false/absent flag returns the plan unchanged.
*
* Only the NUMBERS flip — the array itself stays in packing order. Container
* placements are generated by walking the container units in booking order
* against getContainerSlotSequenceNos(plan) in array order, then matched back to
* their allocation by `sequenceNo:bookingId`. Reordering the array here broke
* that pairing on every reversed schedule: unit 1 was handed the number of the
* slot holding the LAST booking, the match missed, and persistAllocationsAndLoads
* silently dropped every container item — which is why a reversed export train
* printed a marshalling doc with no container numbers and 0/0 container counts.
*/
export function applyWagonOrderReversal(
plan: WagonPlanSlot[],
reverse: boolean | null | undefined,
): WagonPlanSlot[] {
if (!reverse) return plan;
return [...plan]
.reverse()
.map((slot, index) => ({ ...slot, sequenceNo: index + 1 }));
return plan.map((slot, index) => ({ ...slot, sequenceNo: plan.length - index }));
}
/** Unbounded stock — used to compute pure demand for availability reporting. */

View File

@@ -308,6 +308,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
expect(maxEdgeConsistUsage(plan, stops)).toEqual({
grossWeightTons: 89,
lengthMeters: 14,
loadedWagonCount: 1,
});
});
@@ -328,6 +329,7 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({
grossWeightTons: 178,
lengthMeters: 28,
loadedWagonCount: 2,
});
});
});

View File

@@ -3,7 +3,7 @@ import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { consistViolations } from './train-capacity.util';
import { bookingCargoTons, bulkItemWagonsRequired, consistViolations } from './train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;
@@ -171,11 +171,21 @@ export function buildBulkWagonPlan(
bookings: Booking[],
wagonType: WagonType,
): WagonPlanSlot[] {
const totalWeight = roundTons(
bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0),
);
const capacity = Number(wagonType.capacityTons);
const slots = Math.max(1, Math.ceil(totalWeight / capacity));
// Break-bulk (PER_ITEM) bookings size by indivisible items per booking —
// their tonnage must NOT pool with PER_TON cargo (an item can't split
// across wagons the way loose tonnage can).
const itemSlotsByBooking = bookings.map((b) => bulkItemWagonsRequired(b, capacity));
const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0);
const totalWeight = roundTons(
bookings.reduce(
(sum, b, i) =>
itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0),
0,
),
);
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
const slots = Math.max(1, tonSlots + itemSlots);
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
sequenceNo: index + 1,
@@ -293,7 +303,9 @@ function allocateBookingsToSlots(
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
remainingWeightTons: roundTons(Number(booking.cargoTotalWeightVgm ?? 0)),
// bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM)
// bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)),
}));
let bookingIndex = 0;
@@ -537,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge(
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
limits: TrainLimitConfig | undefined,
stops: string[],
/** Display names parallel to `stops` — violations then name the leg they hit. */
stopLabels?: string[],
): string[] {
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
const spans = slotSpans(wagonPlan, stops);
const label = (i: number) => stopLabels?.[i] ?? stops[i];
const violations = new Set<string>();
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = wagonPlan.filter(
@@ -547,15 +562,28 @@ export function validateMixedTrainLimitsPerEdge(
);
if (!active.length) continue;
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
violations.add(violation);
violations.add(`Leg ${label(edge)}${label(edge + 1)}: ${violation}`);
}
}
return [...violations];
}
/**
* The slot fields per-edge usage math actually reads — lets callers feed
* persisted TrainSetWagon rows (or any structural subset), not only plan slots.
*/
export type EdgeUsageSlot = Pick<
WagonPlanSlot,
'lengthMeters' | 'tareWeightTons' | 'assignedWeightTons'
> & {
boardYardId?: string | null;
alightYardId?: string | null;
allocations?: unknown[];
};
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
function slotSpans(
wagonPlan: WagonPlanSlot[],
wagonPlan: EdgeUsageSlot[],
stops: string[],
): Array<{ from: number; to: number }> {
const lastIdx = stops.length - 1;
@@ -575,28 +603,57 @@ function slotSpans(
* Two stops or fewer degrade to the whole-train totals.
*/
export function maxEdgeConsistUsage(
wagonPlan: WagonPlanSlot[],
wagonPlan: EdgeUsageSlot[],
stops: string[],
): { grossWeightTons: number; lengthMeters: number } {
const totals = (slots: WagonPlanSlot[]) => ({
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
return perEdgeConsistUsage(wagonPlan, stops).reduce(
(max, e) => ({
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount),
}),
{ grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 },
);
}
/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */
export type EdgeConsistUsage = {
edge: number;
grossWeightTons: number;
lengthMeters: number;
loadedWagonCount: number;
wagonCount: number;
};
/**
* Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own
* consist totals, so callers can name WHICH leg breaks a limit instead of
* only reporting the heaviest figure. Two stops or fewer collapse to a
* single whole-route edge.
*/
export function perEdgeConsistUsage(
wagonPlan: EdgeUsageSlot[],
stops: string[],
): EdgeConsistUsage[] {
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
edge,
grossWeightTons: slots.reduce(
(sum, w) =>
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
0,
),
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
wagonCount: slots.length,
});
if (stops.length <= 2) return totals(wagonPlan);
if (stops.length <= 2) return [totals(0, wagonPlan)];
const spans = slotSpans(wagonPlan, stops);
const usage = { grossWeightTons: 0, lengthMeters: 0 };
for (let edge = 0; edge < stops.length - 1; edge += 1) {
const active = totals(
return Array.from({ length: stops.length - 1 }, (_, edge) =>
totals(
edge,
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
);
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
}
return usage;
),
);
}
export function validate20ftContainerRules(
@@ -653,6 +710,14 @@ export function validateContainerPlacements(
wagonPlan: WagonPlanSlot[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
/**
* Leg-aware occupancy (cross-leg TEU sharing): booking id → stop-index leg.
* With legs, a wagon's TEU/weight caps hold PER CORRIDOR EDGE — an intercity
* 20ft and an export 20ft coexist on one wagon when their edges allow it.
* Omitted → one edge, byte-identical to the whole-route check.
*/
legs?: Map<string, { from: number; to: number }>,
edgeCount?: number,
): string[] {
const violations: string[] = [];
const units = expandBookingContainerUnits(containerBookings);
@@ -709,8 +774,18 @@ export function validateContainerPlacements(
}
}
const slotTeuUsed = new Map<number, number>();
const slotWeightUsed = new Map<number, number>();
// TEU and weight are tracked PER EDGE of a unit's leg; without legs there is
// a single edge and this is exactly the old whole-route accounting.
const edges = Math.max(1, edgeCount ?? 1);
const legOf = (bookingId: string): { from: number; to: number } => {
const leg = legs?.get(bookingId);
if (!leg || leg.from < 0 || leg.to > edges || leg.from >= leg.to) {
return { from: 0, to: edges };
}
return leg;
};
const slotTeuUsed = new Map<number, number[]>();
const slotWeightUsed = new Map<number, number[]>();
const slotBySeq = new Map(wagonPlan.map((s) => [s.sequenceNo, s]));
for (const placement of placements) {
@@ -722,22 +797,38 @@ export function validateContainerPlacements(
if (!unit) continue;
const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20);
const usedTeu = slotTeuUsed.get(placement.sequenceNo) ?? 0;
if (usedTeu + teu > MAX_TEU_SLOTS_PER_WAGON) {
const leg = legOf(unit.bookingId);
const teuRow =
slotTeuUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let teuFits = true;
for (let e = leg.from; e < leg.to; e += 1) {
if ((teuRow[e] ?? 0) + teu > MAX_TEU_SLOTS_PER_WAGON) {
teuFits = false;
break;
}
}
if (!teuFits) {
violations.push(
`Wagon #${placement.sequenceNo} cannot fit another ${unit.containerTypeCode} (max 1×40ft or 2×20ft per wagon)`,
);
} else {
slotTeuUsed.set(placement.sequenceNo, usedTeu + teu);
for (let e = leg.from; e < leg.to; e += 1) teuRow[e] = (teuRow[e] ?? 0) + teu;
slotTeuUsed.set(placement.sequenceNo, teuRow);
}
const slot = slotBySeq.get(placement.sequenceNo);
if (slot) {
const weight = roundTons(slotWeightUsed.get(placement.sequenceNo) ?? 0) + unit.grossWeightTons;
slotWeightUsed.set(placement.sequenceNo, weight);
if (weight > slot.capacityTons) {
const weightRow =
slotWeightUsed.get(placement.sequenceNo) ?? new Array<number>(edges).fill(0);
let heaviestEdge = 0;
for (let e = leg.from; e < leg.to; e += 1) {
weightRow[e] = roundTons((weightRow[e] ?? 0) + unit.grossWeightTons);
heaviestEdge = Math.max(heaviestEdge, weightRow[e]);
}
slotWeightUsed.set(placement.sequenceNo, weightRow);
if (heaviestEdge > slot.capacityTons) {
violations.push(
`Wagon #${placement.sequenceNo} total container weight ${weight}T exceeds capacity ${slot.capacityTons}T`,
`Wagon #${placement.sequenceNo} total container weight ${heaviestEdge}T exceeds capacity ${slot.capacityTons}T`,
);
}
}

View File

@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
@@ -50,11 +51,11 @@ export class BuildTrainDto {
@IsUUID('all', { each: true })
wagonIds?: string[];
@ApiPropertyOptional({ maxLength: 100 })
@IsOptional()
@ApiProperty({ maxLength: 100, description: 'Vogue number' })
@IsString()
@IsNotEmpty({ message: 'Vogue number is required' })
@MaxLength(100)
trainName?: string;
trainName!: string;
@ApiPropertyOptional()
@IsOptional()

View File

@@ -13,8 +13,11 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -94,8 +97,12 @@ export class TrainBuilderController {
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto);
assignWagons(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignTrainWagonsDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user));
}
@Delete(':id/wagons/:wagonId')
@@ -104,8 +111,9 @@ export class TrainBuilderController {
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.removeWagon(id, wagonId);
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
}
@Post(':id/wagons/:wagonId/maintenance')
@@ -114,8 +122,9 @@ export class TrainBuilderController {
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
}
@Post(':id/reorder-wagons')

View File

@@ -0,0 +1,44 @@
import { orderSlotsByWagonSequence } from './train-builder.service';
describe('orderSlotsByWagonSequence', () => {
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
sequenceNo,
physicalWagonId,
});
it('reorders pinned slots to the wagons new positions, unpinned trail in old order', () => {
// Built train reordered to w3, w1, w2. Slots 1..5: three pinned + two empty.
const newSeq = new Map([
['w3', 1],
['w1', 2],
['w2', 3],
]);
const slots = [
slot(1, 'w1'),
slot(2, 'w2'),
slot(3, 'w3'),
slot(4, null),
slot(5, null),
];
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([
'w3',
'w1',
'w2',
null,
null,
]);
// Unpinned keep their old relative order (4 before 5).
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.sequenceNo)).toEqual([
3, 1, 2, 4, 5,
]);
});
it('slots pinned to wagons outside the reorder trail like unpinned ones', () => {
const newSeq = new Map([['w2', 1]]);
const slots = [slot(1, 'w-foreign'), slot(2, 'w2')];
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([
'w2',
'w-foreign',
]);
});
});

View File

@@ -11,7 +11,12 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -58,7 +63,10 @@ export interface ActiveScheduleRef {
export class TrainBuilderService {
private readonly logger = new Logger(TrainBuilderService.name);
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
@@ -467,19 +475,26 @@ export class TrainBuilderService {
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
userId ?? null,
train.currentYardId ?? null,
);
});
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string) {
async removeWagon(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -497,6 +512,13 @@ export class TrainBuilderService {
status: WagonStatus.Available,
});
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
);
});
return this.getComposition(id);
}
@@ -506,7 +528,7 @@ export class TrainBuilderService {
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
* it clears maintenance. The freed sequence gap is closed.
*/
async sendWagonToMaintenance(id: string, wagonId: string) {
async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -546,6 +568,13 @@ export class TrainBuilderService {
);
}
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
yardId,
);
});
return this.getComposition(id);
}
@@ -573,26 +602,6 @@ export class TrainBuilderService {
return rows.length > 0;
}
/** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */
private async isAnyWagonPinnedToLiveSchedule(
manager: EntityManager,
wagonIds: string[],
): Promise<boolean> {
if (!wagonIds.length) return false;
const rows: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonIds],
);
return rows.length > 0;
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -605,20 +614,63 @@ export class TrainBuilderService {
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
// A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at
// its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so
// renumbering here would silently desync that schedule's drawn consist
// from the built train's real order (loaded slots keep the old order,
// empty ones show the new one). Same guard as remove/maintenance.
if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) {
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
// is allowed — the pinned schedules' consists are resequenced below so
// they can never desync from the built train's real order.
const dispatched: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[[...current]],
);
if (dispatched.length > 0) {
throw new ConflictException(
'This train has wagons pinned to an active schedule and cannot be reordered — ' +
"it would desync the schedule's consist view from the built train's real order.",
'This train is dispatched — wagons cannot be reordered while it is rolling.',
);
}
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
// Propagate the new order to every live (DRAFT/SCHEDULED) schedule of
// this train: slots pinned to a reordered wagon adopt the wagon's new
// position, unpinned slots trail in their old relative order. Allocations
// ride the slot row (by id), so cargo stays with its physical wagon.
const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1]));
const sets: { train_set_id: string }[] = await manager.query(
`SELECT DISTINCT ts.train_set_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')`,
[id],
);
for (const { train_set_id: trainSetId } of sets) {
const slots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId },
order: { sequenceNo: 'ASC' },
});
const sorted = orderSlotsByWagonSequence(slots, newSeq);
// (train_set_id, sequence_no) is unique — shift to a temp range first
// so the final renumbering can't collide mid-loop.
await manager.query(
`UPDATE freight.train_set_wagons
SET sequence_no = sequence_no + 100000
WHERE train_set_id = $1 AND deleted_at IS NULL`,
[trainSetId],
);
for (let i = 0; i < sorted.length; i++) {
await manager
.getRepository(TrainSetWagon)
.update(sorted[i].id, { sequenceNo: i + 1 });
}
}
});
return this.getComposition(id);
}
@@ -781,6 +833,81 @@ export class TrainBuilderService {
};
}
/**
* Train Builder edits a train's physical consist directly on `Wagon.trainId`
* — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a
* wagon added/removed here (while the train already has a live DRAFT/
* SCHEDULED schedule) used to leave the schedule's capacity, history, and
* booking-window status silently stale. This mirrors what
* TrainSchedulingService.adjustScheduleConsist does when the SAME edit is
* made from the schedule's own consist editor, so both entry points agree.
*/
private async syncLiveScheduleAfterConsistChange(
manager: EntityManager,
trainId: string,
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
): Promise<void> {
if (!changes.length) return;
const trainSet = await manager
.getRepository(TrainSet)
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
const schedule = trainSet
? await manager.getRepository(TrainSchedule).findOne({
where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) },
})
: null;
const consist = await manager.getRepository(Wagon).find({
where: { trainId },
relations: { wagonType: true },
});
const wagonCount = consist.length;
const totalWeightTons = round(
consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0),
);
const totalLengthMeters = round(
consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0),
);
if (trainSet) {
await manager
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
const now = new Date();
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
changes.map((c) =>
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: schedule.id,
trainId,
action: c.action,
wagonId: c.wagonId,
wagonNumber: c.wagonNumber,
adjustedByUserId: userId,
yardId,
occurredAt: now,
}),
),
);
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
// schedule reopens its booking window; filling the last one closes it.
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
if (!usage) return;
const nowFull = usage.remainingSlots <= 0;
if (wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(schedule.id);
} else if (!wasFull && nowFull) {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
}
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
const train = await manager.getRepository(Train).findOne({
@@ -856,7 +983,7 @@ export class TrainBuilderService {
train: Train,
wagonIds: string[],
startCount: number,
): Promise<void> {
): Promise<Wagon[]> {
const uniqueIds = [...new Set(wagonIds)];
const wagonRepo = manager.getRepository(Wagon);
@@ -883,7 +1010,7 @@ export class TrainBuilderService {
}
toAttach.push(wagon);
}
if (!toAttach.length) return;
if (!toAttach.length) return [];
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
@@ -896,6 +1023,7 @@ export class TrainBuilderService {
status: WagonStatus.Assigned,
});
}
return toAttach;
}
/**
@@ -963,3 +1091,20 @@ export class TrainBuilderService {
}
}
}
/**
* New consist order for a schedule's slots after a built-train reorder: slots
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
* trail behind in their previous relative order.
*/
export function orderSlotsByWagonSequence<
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
>(slots: T[], newSeq: Map<string, number>): T[] {
const key = (s: T): number =>
(s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity;
return [...slots].sort((a, b) => {
const sa = key(a);
const sb = key(b);
return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo;
});
}

View File

@@ -1,6 +1,7 @@
// apps/edr-freight-api/src/modules/trains/trains.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { TrainBuilderController } from './train-builder.controller';
@@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule],
controllers: [TrainsController, TrainBuilderController],
providers: [TrainsService, TrainBuilderService],
exports: [TrainsService, TrainBuilderService],

Some files were not shown because too many files have changed in this diff Show More