Merge pull request #940 from Tria-plc/dev

deploy freight
This commit is contained in:
marshal
2026-07-23 16:40:26 +03:00
committed by GitHub
902 changed files with 67080 additions and 11838 deletions

18
.gitignore vendored
View File

@@ -29,3 +29,21 @@ coverage/
\#*\#
.\#*
docker-compose.override.yml
# cypress e2e artifacts
e2e/**/cypress/videos/
e2e/**/cypress/screenshots/
e2e/**/cypress/downloads/
# e2e launcher state (ports of the running stack)
e2e/freight/.e2e-ports.json
# local run scripts (contain personal DB credentials — never commit)
run-passenger-local.sh
run-passenger-web.sh
# generated test output
e2e-ui-report/
test-results/
playwright-report/
blob-report/

View File

@@ -22,6 +22,10 @@ TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# Public origin of the freight customer portal. Password-reset links sent to
# customers are built against this — it must be browser-reachable.
FREIGHT_PORTAL_URL=http://localhost:5173
# Portal pages the payment provider redirects the browser to after payment.
# Point these at the freight portal's public payment result routes.
PAYMENT_RETURN_URL=http://localhost:5173/payment/success

View File

@@ -14,6 +14,7 @@
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts",
"seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts",
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",

View File

@@ -12,7 +12,7 @@ import {
ensurePostgresSchemas,
APPLICATION_SEARCH_PATH,
} from "./config/ensure-postgres-schemas";
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
import { IamModule } from "@tria-plc/iamapi-common";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import appConfig from "./config/app.config";
@@ -39,10 +39,12 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
import { OtpModule } from "./modules/otp/otp.module";
import { HealthModule } from "./modules/health/health.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
@@ -59,6 +61,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder";
import { PaymentModule } from "./modules/payment/payment.module";
// import { PricingDataSeeder } from "./seed/pricing-data.seeder";
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder";
// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
@@ -74,8 +77,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -101,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware";
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
load: [
appConfig,
databaseConfig,
telebirrConfig,
rabbitmqConfig,
faydaConfig,
],
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
@@ -159,10 +168,12 @@ import { LoggerMiddleware } from "./logger.middleware";
BillingModule,
NotificationsModule,
NotificationInboxModule,
SupportChatModule,
FileUploadSettingsModule,
DropdownSettingsModule,
ContractTemplatesModule,
OtpModule,
HealthModule,
RuleEngineModule,
BackofficeModule,
DemoPermissionsModule,
@@ -196,6 +207,7 @@ import { LoggerMiddleware } from "./logger.middleware";
EdrOrgSeeder,
FreightPositionsSeeder,
FileUploadSettingsSeeder,
YardFacilitiesSeeder,
FreightPermissionKeyMigrationSeeder,
// Disabled seeds — providers commented out (imports/injection/run too):
// DemoUsersSeeder,
@@ -217,10 +229,11 @@ import { LoggerMiddleware } from "./logger.middleware";
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly seeder: DataSeeder,
// private readonly seeder: DataSeeder,
private readonly edrOrgSeeder: EdrOrgSeeder,
private readonly freightPositionsSeeder: FreightPositionsSeeder,
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
// Disabled seeds — injections commented out (imports/provider/run too):
// private readonly demoUsersSeeder: DemoUsersSeeder,
@@ -251,13 +264,17 @@ export class AppModule implements OnApplicationBootstrap {
// freightPositionsSeeder → seeds Position + PositionPermission rows
// (depends on edrOrgSeeder, must run after)
await this.freightPermissionKeyMigrationSeeder.run();
await this.seeder.run();
// await this.seeder.run();
await this.edrOrgSeeder.run();
await this.freightPositionsSeeder.run();
// File upload settings — keep enabled.
await this.fileUploadSettingsSeeder.run();
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
// Dire Dawa). Idempotent; creates no yards.
await this.yardFacilitiesSeeder.run();
// Dropdown settings are not seeded on boot; run them with
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
@@ -266,6 +283,9 @@ export class AppModule implements OnApplicationBootstrap {
// await this.demoUsersSeeder.run();
// await this.freightStaffUsersSeeder.run();
// await this.pricingDataSeeder.run();
// IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so
// it will not recognise a hand-created Indode warehouse and will seed a second
// one alongside it. Only enable it against an Indode that has no warehouse.
// await this.indodeFacilitySeeder.run();
// await this.batch14TestDataSeeder.run();
// await this.batch5TestDataSeeder.run();

View File

@@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) =>
),
);
/**
* Read-only reference data (yard dropdowns, search filters): any signed-in
* staff. Menu/page visibility stays permission-gated in the frontend — this
* only lets forms populate their lookups.
*/
export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const TrainSchedulingView = () =>
@@ -22,9 +29,22 @@ export const TrainSchedulingView = () =>
export const TrainSchedulingManage = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.manage);
export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view);
/**
* Fleet guards take an optional granular per-resource key (locomotives:create,
* wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain
* valid as a one-of fallback so existing role grants keep working.
*/
export const FleetView = (granular?: string) =>
BookingStaff(
granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view,
);
export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage);
export const FleetManage = (granular?: string) =>
BookingStaff(
granular
? [granular, FREIGHT_PERMS.fleet.manage]
: FREIGHT_PERMS.fleet.manage,
);
/** Requester creates a wagon-transfer request (count-only, no wagon picks). */
export const WagonTransferRequest = () =>

View File

@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { assertExportReceivedWithGrn } from './export-received-gate';
const db = (rows: unknown[]) =>
({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource;
describe('assertExportReceivedWithGrn', () => {
it('passes when the export booking has a received row with a GRN', async () => {
await expect(
assertExportReceivedWithGrn(db([{ '?column?': 1 }]), {
id: 'b-1',
tradeDirection: 'EXPORT',
}),
).resolves.toBeUndefined();
});
it('rejects an export booking with nothing received', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('never blocks import — it loads off a train, not out of the warehouse', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }),
).resolves.toBeUndefined();
// Import short-circuits before querying.
expect((source.query as jest.Mock)).not.toHaveBeenCalled();
});
it('does not block intercity cargo', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
/** The booking fields the gate needs. */
export interface ExportLoadGateBooking {
id: string;
tradeDirection?: string | null;
}
/**
* Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by
* the customer's own truck, and even though a wagon is already allocated. An
* allocation is a plan; the GRN is the proof the goods are actually in hand.
*
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
* loaded straight off the allocation, skipping the warehouse, so a booking could
* ride the train with nothing ever received. This closes that for export; import
* loads off a train and is unaffected.
*
* "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use.
*/
export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager,
booking: ExportLoadGateBooking,
): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return;
const [row] = await db.query(
`SELECT 1
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
AND inv.deleted_at IS NULL
AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED')
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
LIMIT 1`,
[booking.id],
);
if (!row) {
throw new BadRequestException(
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
);
}
}

View File

@@ -0,0 +1,51 @@
import {
assertCanApproveContractStep,
canEditContractStep,
} from './freight-permission.util';
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
// The document-edit gate (canEditContractStep) must be STRICT: only the approver
// whose turn it is may edit. This is the fix for a previous approver keeping the
// "Edit contract articles" button after acting, because the approve gate lets
// through anyone holding any contract-approve permission.
describe('canEditContractStep (strict per-step edit gate)', () => {
const director = {
employee: { position: { positionType: { key: '-marketing-director-' } } },
};
// A line staff who already approved their own step but still holds a
// contract-approve permission — the exact actor that leaked edit rights.
const officerWithApprovePerm = {
employee: {
position: {
positionType: { key: '-marketing-officer-' },
permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
},
},
};
const superAdmin = { roles: [{ key: 'super_admin' }] };
it('lets the steps own approver edit', () => {
expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
});
it('lets an approval admin edit any step', () => {
expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
});
it('does NOT let a different approver edit just because they hold an approve permission', () => {
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
false,
);
});
it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
// The approve gate passes the officer via the any-permission blanket…
expect(() =>
assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
).not.toThrow();
// …but the edit gate does not — that divergence IS the fix.
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
false,
);
});
});

View File

@@ -7,16 +7,23 @@ const SUPER_ADMIN_ROLE = 'super_admin';
const ORGANIZATION_ADMIN_ROLE = 'organization_admin';
type PermissionLike = { key?: string };
type PositionTypeLike = { key?: string };
type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
position?: { permissions?: PermissionLike[] };
position?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
};
delegatedPositions?: { permissions?: PermissionLike[] }[];
}
| {
positions?: { permissions?: PermissionLike[] }[];
positions?: {
permissions?: PermissionLike[];
positionType?: PositionTypeLike | null;
}[];
}[]
| null;
};
@@ -90,12 +97,138 @@ export function assertFreightPermission(
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/**
* The caller's IAM position-type keys (`iam.position_types.key`). A position
* type is the platform's notion of a role — it is what carries permissions via
* `iam.position_type_permissions` — and it is the vocabulary contract approval
* chains are configured in.
*
* Mirrors `collectPermissionKeys`' handling of both JWT shapes: `employee` is
* an object on some tokens and an array on others.
*
* Note delegated positions carry no `positionType` in the token, so a delegate
* is not reachable here — they authorize through the permission arm of
* `assertCanApproveContractStep` instead.
*/
export function collectPositionTypeKeys(
user: MeLikeUser | null | undefined,
): string[] {
const employee = user?.employee;
if (!employee) return [];
const keys = new Set<string>();
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
if (pos.positionType?.key) keys.add(pos.positionType.key);
}
}
return [...keys];
}
if (employee.position?.positionType?.key) {
keys.add(employee.position.positionType.key);
}
return [...keys];
}
/**
* Legacy chain roles predate position types. Historical `approval_rules` and
* in-flight `contract_approval_steps` rows still carry them, so map each to the
* position types that stand in for it. Without this, an approver holding a
* modern position type could not action an older step.
*/
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
LINE_STAFF: ['employee', 'teamLeader', 'officeHead', 'recordOfficer'],
DIRECTOR: ['director', 'operation-director'],
CEO: ['chief', 'deputy'],
};
const APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.bookings.approveDirector,
CEO: FREIGHT_PERMS.bookings.approveCeo,
};
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
CEO: FREIGHT_PERMS.contracts.approveCeo,
};
const ANY_CONTRACT_APPROVE_PERMISSION = [
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
];
/**
* May this caller action a contract approval step requiring `requiredRole`?
*
* `requiredRole` is an `iam.position_types.key` for chains configured by an
* admin, or one of the legacy LINE_STAFF/DIRECTOR/CEO strings for older rows.
* A caller passes when any of these hold:
*
* - they are a super/organization admin (blanket bypass);
* - their position type matches the step, directly or via a legacy alias;
* - they hold the approve permission the legacy role maps to;
* - they hold any contract approve permission — this covers delegates (whose
* position type is absent from the token) and staff whose IAM position has
* no position type assigned yet.
*/
export function assertCanApproveContractStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): void {
if (isFreightApprovalAdmin(user)) return;
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
if (legacyPermission && hasFreightPermission(user, legacyPermission)) return;
if (ANY_CONTRACT_APPROVE_PERMISSION.some((p) => hasFreightPermission(user, p))) {
return;
}
throw new ForbiddenException(
`You are not the required approver (${requiredRole}) for this step.`,
);
}
/**
* Strict "is it exactly this caller's turn?" test — mirrors the backoffice
* `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep}
* EXCEPT the blanket "holds any contract-approve permission" fallback is
* dropped: a line-staff holding `approveLineStaff` must NOT read as the director
* for a director step. Used to gate contract-document editing so approval hands
* edit rights to the NEXT approver only — a previous approver who already acted
* (but still holds an approve permission) loses the edit button, as required.
*
* (Kept separate from the approve/reject gate, which keeps the blanket fallback
* so delegates whose token omits a position type can still action their step.)
*/
export function canEditContractStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
): boolean {
if (isFreightApprovalAdmin(user)) return true;
const positionTypes = collectPositionTypeKeys(user);
if (positionTypes.includes(requiredRole)) return true;
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission));
}
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,

View File

@@ -0,0 +1,13 @@
/**
* Goods Received Note number: `GRN-<DIRECTION>-<YYYYMMDD>-<REF8>`.
*
* Shared so a GRN raised at a load/unload facility is indistinguishable from one
* raised in a warehouse — the two live in different tables
* (facility_handling_events vs warehouse_inventory), and a second generator would
* eventually let their formats drift apart.
*/
export function generateGrnNumber(direction: string, referenceId: string, date: Date): string {
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
}

View File

@@ -0,0 +1,61 @@
import { DataSource } from 'typeorm';
type MileRecord = {
bookingId?: string | null;
advancedPayment?: number | string | null;
booking?: {
cargoTotalWeightVgm?: number | string | null;
bookingContainers?: Array<{
units?: Array<{ vgmTons?: number | string | null }> | null;
}> | null;
} | null;
};
/**
* Display enrichment for first/last-mile lists (Assign Vehicle modal etc.):
* - Advance payment: mile records are created with advanced_payment 0 — the
* real advance is the FIRST_MILE/LAST_MILE line the customer already paid
* on the booking invoice.
* - Cargo tons: container bookings often carry tonnage on the per-unit VGMs
* while cargo_total_weight_vgm stays 0 — fall back to the summed units.
* Fills both in-memory on the loaded records; nothing is persisted.
*/
export async function attachMileFinancials(
dataSource: DataSource,
records: MileRecord[],
chargeType: 'FIRST_MILE' | 'LAST_MILE',
): Promise<void> {
for (const r of records) {
const b = r.booking;
if (!b || Number(b.cargoTotalWeightVgm) > 0) continue;
const unitTons = (b.bookingContainers ?? []).reduce(
(sum, bc) =>
sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0),
0,
);
if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3));
}
const needAdvance = records.filter(
(r) => r.bookingId && !(Number(r.advancedPayment) > 0),
);
if (!needAdvance.length) return;
const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query(
`SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount
FROM freight.invoice_lines il
JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL
WHERE i.source = 'booking'
AND i.status = 'PAID'
AND i.source_id = ANY($1::text[])
AND il.charge_type = $2
AND il.deleted_at IS NULL
GROUP BY i.source_id`,
[needAdvance.map((r) => r.bookingId), chargeType],
);
const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)]));
for (const r of needAdvance) {
const paid = byBooking.get(r.bookingId as string);
if (paid) r.advancedPayment = paid;
}
}

View File

@@ -0,0 +1,52 @@
import { usesEdrMileService } from './mile-haulage.util';
/**
* The road legs are chosen on the contract and copied onto the booking. EDR
* haulage and a customer's own truck are alternatives, so this one answer gates
* both sides — the customer-truck guard and the mile-queue guard.
*/
describe('usesEdrMileService', () => {
const booking = (over: Partial<Parameters<typeof usesEdrMileService>[0]> = {}) => ({
tradeDirection: 'IMPORT',
firstMile: null,
lastMile: null,
...over,
});
it('an import that chose delivery uses EDR haulage', () => {
expect(usesEdrMileService(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(true);
});
it('an import that chose nothing does not', () => {
expect(usesEdrMileService(booking())).toBe(false);
});
it('ignores the pickup address on an import — collection is the export leg', () => {
expect(usesEdrMileService(booking({ firstMile: 'Modjo' }))).toBe(false);
});
it('an export that chose collection uses EDR haulage', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
).toBe(true);
});
it('ignores the delivery address on an export — delivery is the import leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
).toBe(false);
});
it('a domestic booking counts either leg', () => {
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
).toBe(true);
expect(
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
).toBe(true);
});
it('treats a whitespace-only address as no choice', () => {
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
});
});

View File

@@ -0,0 +1,49 @@
/** The booking fields that decide who hauls the road legs. */
export interface MileHaulageRow {
tradeDirection: string | null;
/** `first_mile_pickup_address` — set when the customer asked EDR to collect. */
firstMile: string | null;
/** `last_mile_delivery_address` — set when the customer asked EDR to deliver. */
lastMile: string | null;
}
/**
* Whether the customer bought the EDR road leg that matters for their direction:
* delivery at the end of an import, collection at the start of an export. A
* DOMESTIC booking can use either, so either one counts.
*
* The address is the signal because it is the only per-booking record of the
* choice. `service_types.includes_first_mile` / `includes_last_mile` cannot be
* used — every service type ships with both set to true, so reading them would
* mean every booking uses EDR haulage and none could ever self-haul.
*/
export function usesEdrMileService(booking: MileHaulageRow): boolean {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const hasLastMile = Boolean(booking.lastMile?.trim());
switch (booking.tradeDirection) {
case 'IMPORT':
return hasLastMile;
case 'EXPORT':
return hasFirstMile;
default:
return hasFirstMile || hasLastMile;
}
}
/**
* EDR haulage and a customer's own truck are alternatives, never both. Whichever
* side is being set up, it has to reject the other — a guard on only one side
* lets the two paths open on the same booking, each unaware of the other.
*/
export const SELF_HAUL_CONFLICT_MESSAGE =
'This booking is delivered by the customers own truck — an EDR mile leg cannot also be assigned.';
export const EDR_HAULAGE_CONFLICT_MESSAGE =
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
/**
* The road legs are chosen on the contract. A booking whose contract bought
* neither has no business in the first/last-mile queues at all.
*/
export const NO_MILE_SERVICE_MESSAGE =
'This booking did not select first/last mile delivery on its contract, so it cannot be assigned an EDR mile leg.';

View File

@@ -4,6 +4,7 @@ import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { FreightPermissionGuard } from './freight-permission.guard';
import {
FREIGHT_PERMS,
type RuleEngineApprovableSlug,
type RuleEngineResourceSlug,
} from '../seed/freight-permissions.registry';
@@ -16,3 +17,13 @@ export const RuleEngineManage = (slug: RuleEngineResourceSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])),
);
/**
* Deciding a filed change — a step above `manage`, which only lets a staff
* member propose one. Super admins pass any freight permission check, so
* approvals work before the permission is granted to a director role.
*/
export const RuleEngineApprove = (slug: RuleEngineApprovableSlug) =>
applyDecorators(
UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.approve(slug)])),
);

View File

@@ -0,0 +1,28 @@
/**
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
*
* A booking reaches a train through WAGON ALLOCATION
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
* written by the demo seeders, so both sources are unioned: real allocations work
* and the seeded scenarios keep working.
*
* Shared so the warehouse loading queue and the train dispatch guard agree on
* exactly which bookings are on a train — if they drift, a train can be
* dispatched leaving cargo the warehouse still thinks it should load.
*/
export const SCHEDULE_BOOKINGS_CTE = `
sched_bookings AS (
SELECT ts.id AS schedule_id, wba.booking_id
FROM freight.train_schedules ts
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations wba
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
WHERE ts.deleted_at IS NULL
UNION
SELECT tsb.train_schedule_id, tsb.booking_id
FROM freight.train_schedule_bookings tsb
WHERE tsb.deleted_at IS NULL
)`;

View File

@@ -0,0 +1,159 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
assertTruckLoad,
remainingBulkTons,
} from './truck-load.util';
/**
* One physical rule, shared by customer self-haul and EDR last-mile. It used to
* be written out three times (addTruck, updateTruck, departTruck) plus a fourth
* in LastMileService.
*/
describe('assertTruckLoad', () => {
const booking = ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'];
it('accepts two 20ft containers on one truck', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['20ft', '20ft'],
}),
).not.toThrow();
});
it('accepts a single 40ft container', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567'],
bookingContainers: booking,
sizes: ['40ft'],
}),
).not.toThrow();
});
it('rejects a 40ft sharing the truck — it fills the bed', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321'],
bookingContainers: booking,
sizes: ['40ft', '20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects more than two containers', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567', 'ABCD7654321', 'WXYZ1111111'],
bookingContainers: booking,
sizes: ['20ft', '20ft', '20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects a container that is not on the booking', () => {
expect(() =>
assertTruckLoad({
containers: ['ZZZZ9999999'],
bookingContainers: booking,
sizes: ['20ft'],
}),
).toThrow(BadRequestException);
});
it('rejects a container already riding another truck', () => {
expect(() =>
assertTruckLoad({
containers: ['ABCD1234567'],
bookingContainers: booking,
sizes: ['20ft'],
assignedElsewhere: ['ABCD1234567'],
}),
).toThrow(ConflictException);
});
it('skips membership checks when the booking has no containers (bulk)', () => {
expect(() =>
assertTruckLoad({ containers: [], bookingContainers: [], sizes: [] }),
).not.toThrow();
});
it('still caps the count when the booking has no containers', () => {
expect(() =>
assertTruckLoad({
containers: ['A', 'B', 'C'],
bookingContainers: [],
sizes: [],
}),
).toThrow(BadRequestException);
});
});
describe('assertBulkTonnageRemains', () => {
it('allows another truck while tonnage is left', () => {
expect(() => assertBulkTonnageRemains(100, 40)).not.toThrow();
});
it('rejects a truck once the booking is fully hauled', () => {
expect(() => assertBulkTonnageRemains(100, 0)).toThrow(BadRequestException);
});
it('does not cap a booking with no declared weight', () => {
// Nothing to draw down against — capping here would block every truck.
expect(() => assertBulkTonnageRemains(0, 0)).not.toThrow();
});
});
describe('remainingBulkTons', () => {
const dataSourceReturning = (totalTons: string, hauledTons: string) =>
({ query: jest.fn().mockResolvedValue([{ totalTons, hauledTons }]) }) as never;
it('counts trucks from both haulage paths against the declared weight', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '60'), 'b-1');
expect(result).toEqual({
totalTons: 100,
hauledTons: 60,
remainingTons: 40,
complete: false,
});
});
it('is complete once everything is hauled', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '100'), 'b-1');
expect(result.remainingTons).toBe(0);
expect(result.complete).toBe(true);
});
it('never reports negative tonnage when trucks overshoot', async () => {
const result = await remainingBulkTons(dataSourceReturning('100', '104'), 'b-1');
expect(result.remainingTons).toBe(0);
expect(result.complete).toBe(true);
});
it('is not complete for a booking with no declared weight', async () => {
const result = await remainingBulkTons(dataSourceReturning('0', '0'), 'b-1');
expect(result.complete).toBe(false);
});
});
describe('assertTruckCountWithinContainers', () => {
it('allows one truck per container', () => {
expect(() => assertTruckCountWithinContainers(3, 3)).not.toThrow();
});
it('rejects more trucks than containers', () => {
expect(() => assertTruckCountWithinContainers(4, 3)).toThrow(BadRequestException);
});
it('does not cap a bulk booking, which has no container count', () => {
expect(() => assertTruckCountWithinContainers(9, 0)).not.toThrow();
});
});

View File

@@ -0,0 +1,148 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
/** Two 20ft containers fit a truck bed; one 40ft fills it. */
export const MAX_CONTAINERS_PER_TRUCK = 2;
/**
* What one truck is being asked to carry, and the booking context to judge it
* against. `sizes` are the container_size labels of `containers`, in any order —
* only whether a 40ft is present matters.
*/
export interface TruckLoadCheck {
containers: string[];
/** Every container number on the booking. Empty means nothing to validate against. */
bookingContainers: string[];
sizes: string[];
/** Containers already riding another truck on this booking. */
assignedElsewhere?: string[];
}
/**
* The physical rule for loading one truck, shared by both haulage paths.
*
* A customer's own truck and an EDR last-mile truck obey the same physics, but
* the rule was implemented twice — once in CustomerTruckService, once in
* LastMileService — along with a byte-identical container-size query. Two copies
* of one rule drift, and that is exactly how the self-haul guard ended up
* enforced on one side only.
*/
export function assertTruckLoad({
containers,
bookingContainers,
sizes,
assignedElsewhere = [],
}: TruckLoadCheck): void {
if (containers.length > MAX_CONTAINERS_PER_TRUCK) {
throw new BadRequestException(
`A truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers`,
);
}
// With no container list on the booking there is nothing to check membership
// against — bulk bookings take this path.
if (!bookingContainers.length) return;
for (const number of containers) {
if (!bookingContainers.includes(number)) {
throw new BadRequestException(
`Container ${number} is not one of this booking's containers`,
);
}
if (assignedElsewhere.includes(number)) {
throw new ConflictException(`Container ${number} is already loaded onto another truck`);
}
}
// A 40ft fills the bed, so it travels alone.
if (containers.length > 1 && sizes.some((size) => size.includes('40'))) {
throw new BadRequestException(
'A 40ft container fills the truck — assign only 1 container to this truck',
);
}
}
/** Never put more trucks on a booking than it has containers to fill them. */
export function assertTruckCountWithinContainers(
truckCount: number,
bookingContainerCount: number,
): void {
if (bookingContainerCount > 0 && truckCount > bookingContainerCount) {
throw new BadRequestException(
`Cannot assign more trucks than containers — this booking has ${bookingContainerCount} container(s) and ${truckCount} truck(s) requested.`,
);
}
}
/**
* How much of a bulk booking is still to be hauled. Counts trucks from BOTH
* haulage paths — a booking uses one or the other, and the rule ("trucks until
* no tonnage is left") is the same either way, so a single sum keeps them from
* disagreeing.
*
* Only departed trucks count: tonnage is known once the truck is weighed out.
*/
export async function remainingBulkTons(
dataSource: DataSource,
bookingId: string,
): Promise<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }> {
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
await dataSource.query(
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
COALESCE((
SELECT SUM(va.net_weight_tons)
FROM freight.last_mile_vehicle_assignments va
JOIN freight.last_mile lm
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
WHERE lm.booking_id = b.id
AND va.deleted_at IS NULL
AND va.departed_at IS NOT NULL
), 0)
+ COALESCE((
SELECT SUM(a.net_weight_tons)
FROM freight.customer_truck_assignments a
WHERE a.booking_id = b.id
AND a.deleted_at IS NULL
AND a.departed_at IS NOT NULL
), 0) AS "hauledTons"
FROM freight.bookings b
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
const totalTons = Number(row?.totalTons ?? 0);
const hauledTons = Number(row?.hauledTons ?? 0);
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
}
/** A fully-hauled bulk booking has nothing left for another truck to carry. */
export function assertBulkTonnageRemains(totalTons: number, remainingTons: number): void {
if (totalTons > 0 && remainingTons <= 0) {
throw new BadRequestException(
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
);
}
}
/**
* container_size labels for the given container numbers on a booking. Shared so
* the two haulage paths read sizes the same way.
*/
export async function bookingContainerSizes(
dataSource: DataSource,
bookingId: string,
numbers: string[],
): Promise<string[]> {
if (!numbers.length) return [];
const rows: Array<{ size: string | null }> = await dataSource.query(
`SELECT bc.container_size AS "size"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND UPPER(bcu.container_number) = ANY($2)
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return rows.map((row) => (row.size ?? '').trim());
}

View File

@@ -0,0 +1,72 @@
import { validate } from 'class-validator';
import { IsISO8601, IsOptional } from 'class-validator';
import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator';
class Subject {
@IsOptional()
@IsISO8601()
@IsNotBackdated()
occurredAt?: string;
}
const subjectWith = (occurredAt?: string) => {
const subject = new Subject();
subject.occurredAt = occurredAt;
return subject;
};
const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt));
const backdatedErrors = (errors: Awaited<ReturnType<typeof errorsFor>>) =>
errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated'));
describe('IsNotBackdated', () => {
it('rejects a timestamp from the past', async () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const errors = await errorsFor(yesterday);
expect(backdatedErrors(errors)).toHaveLength(1);
expect(errors[0].constraints?.IsNotBackdated).toBe(
'occurredAt cannot be backdated — it must be now or later',
);
});
it('accepts now', async () => {
const errors = await errorsFor(new Date().toISOString());
expect(errors).toHaveLength(0);
});
it('accepts a value stale only by transit and clock skew', async () => {
// What an honest caller sends: "now" as of when the request was built.
const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString();
const errors = await errorsFor(almostNow);
expect(errors).toHaveLength(0);
});
it('rejects a value staler than the skew allowance', async () => {
const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString();
const errors = await errorsFor(tooStale);
expect(backdatedErrors(errors)).toHaveLength(1);
});
it('ignores an absent value so @IsOptional decides', async () => {
const errors = await errorsFor(undefined);
expect(errors).toHaveLength(0);
});
it('leaves an unparseable value to the format validator', async () => {
const errors = await errorsFor('not-a-date');
// Reported as a format problem, not as a backdate.
expect(backdatedErrors(errors)).toHaveLength(0);
expect(errors[0].constraints).toHaveProperty('isIso8601');
});
});

View File

@@ -0,0 +1,56 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/**
* A caller may not stamp an event as having happened before now.
*
* A request cannot reach the server at the instant it was built, and a caller's
* clock is not the server's, so a timestamp that honestly means "now" always
* arrives a little stale. Comparing straight against `Date.now()` would reject
* it. The skew allowance below is what makes an honest "now" pass — it is not a
* window for backdating, and it is deliberately far too small to reach any
* earlier event worth backdating to.
*/
export const CLOCK_SKEW_TOLERANCE_MS = 60_000;
@ValidatorConstraint({ name: 'IsNotBackdated', async: false })
export class IsNotBackdatedConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
// Absence is not this validator's business; pair with @IsOptional.
if (value === undefined || value === null || value === '') return true;
const parsed = new Date(value as string | Date);
// An unparseable value is a format error — let @IsISO8601/@IsDateString own
// that message rather than reporting it as a backdate.
if (Number.isNaN(parsed.getTime())) return true;
const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS;
return parsed.getTime() >= Date.now() - toleranceMs;
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} cannot be backdated — it must be now or later`;
}
}
/**
* Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}.
* Pass a different tolerance only with a reason.
*/
export function IsNotBackdated(
toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS,
validationOptions?: ValidationOptions,
) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [toleranceMs],
validator: IsNotBackdatedConstraint,
});
};
}

View File

@@ -0,0 +1,54 @@
import {
validate,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
import { IsTin, normalizeTin } from './is-tin.validator';
class Required {
@IsString()
@IsNotEmpty()
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin!: string;
}
class Optional {
@IsOptional()
@IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' })
tin?: string;
}
async function errs(cls: any, tin: any) {
const o = new cls();
o.tin = tin;
return (await validate(o)).length;
}
describe('IsTin', () => {
it('accepts a real 10-digit TIN', async () => {
expect(await errs(Required, '0012345678')).toBe(0);
});
it.each([
['letters', 'ABCDEFGHIJ'],
['symbols', '!!!!!!!!!!'],
['too short', '123'],
['too long', '12345678901'],
['draft TIN', 'D123456789'],
['spaced', '012 345678'],
])('rejects %s', async (_label, value) => {
expect(await errs(Required, value)).toBeGreaterThan(0);
});
it('rejects empty on the required DTO but allows omission on the optional one', async () => {
expect(await errs(Required, '')).toBeGreaterThan(0);
expect(await errs(Optional, undefined)).toBe(0);
});
it('normalizes messy input', () => {
expect(normalizeTin(' 001-234-5678 ')).toBe('0012345678');
expect(normalizeTin('')).toBe('');
});
});

View File

@@ -0,0 +1,54 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/** An Ethiopian TIN is exactly 10 digits. */
export const TIN_REGEX = /^\d{10}$/;
/**
* Draft companies carry a placeholder TIN ("D" + 9 digits) minted server-side by
* CompaniesService.generateDraftTin(), because the column is NOT NULL + unique.
* Those never travel through a DTO, so this constraint deliberately rejects them
* — a "D…" value arriving on a request body is client-supplied and invalid.
*/
@ValidatorConstraint({ name: 'IsTin', async: false })
export class IsTinConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
return TIN_REGEX.test(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be exactly 10 digits`;
}
}
/** Class-validator decorator enforcing the 10-digit TIN format. */
export function IsTin(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsTinConstraint,
});
};
}
/**
* Strip everything that isn't a digit and cap at 10 characters. Tolerant —
* never throws; returns the value unchanged when empty/nullish.
*/
export function normalizeTin(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
return value.replace(/\D/g, '').slice(0, 10);
}

View File

@@ -9,6 +9,14 @@ export default registerAs("app", () => ({
env: process.env.NODE_ENV ?? "development",
port: parseInt(process.env.PORT ?? "3001", 10),
apiPrefix: "api",
/**
* Public origin of the freight customer portal. Password-reset links mailed
* or SMS'd to customers are built against this, so it must be the address the
* customer's browser can actually reach — not an internal service name.
*/
portalBaseUrl: (
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
).replace(/\/+$/, ""),
trainScheduling: {
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),

View File

@@ -13,6 +13,9 @@ export interface RenderedClause {
/** A dynamic article ready for the Handlebars template. */
export interface RenderedArticle {
number: number;
/** Stable article id from the template (e.g. "pricing") — lets the layout
* inject the live rate schedule table under the pricing article. */
id: string;
title: string;
/** Set (instead of clauses) when the body is a single plain paragraph. */
paragraph?: string;

View File

@@ -18,6 +18,7 @@ import {
ContractDynamicTemplateView,
ContractViewModel,
} from './contract-view-model.builder';
import { RateSchedule } from './contract-rate-schedule.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
@@ -135,6 +136,7 @@ export class ContractDocumentViewModelBuilder {
}
const pricing = this.buildPricing(contract);
const rateSchedule = this.buildRateSchedule(pricing);
const signatures = await this.loadSignatures(contractId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
@@ -177,6 +179,7 @@ export class ContractDocumentViewModelBuilder {
},
schedule: this.buildSchedule(contract),
pricing: pricing as unknown as ContractViewModel['pricing'],
rateSchedule,
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
@@ -230,6 +233,40 @@ export class ContractDocumentViewModelBuilder {
};
}
/**
* A rate schedule for the contract PDF, sourced from the contract's own frozen
* unit rates (its agreed lane prices) rather than the global rate config — a
* signed contract must show the prices it was signed on. Rendered as freight
* lanes labelled with the contract's primary origin → destination route.
*/
private buildRateSchedule(pricing: ContractUnitRateSchedule): RateSchedule {
const route = `${pricing.originLabel}${pricing.destinationLabel}`;
const freightLanes = pricing.unitRates.map((line) => ({
route,
cargo: line.label,
currency: line.currency,
amount: this.formatAmount(line.unitPrice),
unit: line.unit.startsWith('per ') ? line.unit : `per ${line.unit}`,
}));
return {
freightLanes,
additionalServices: [],
surcharges: [],
isEmpty: freightLanes.length === 0,
currencyLabel: pricing.currency,
};
}
private formatAmount(value: number | string): string {
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
const cargoScope = (contract.cargoScope ?? [])[0];

View File

@@ -133,6 +133,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
originLabel: 'Nagad',
destinationLabel: 'Galaan Multipurpose Port',
} as unknown as ContractViewModel['pricing'],
rateSchedule: {
freightLanes: [
{ route: 'Nagad → Galaan Multipurpose Port', cargo: 'Wheat', currency: 'USD', amount: '100', unit: 'per wagon' },
],
additionalServices: [
{ route: 'First-mile pickup by truck', cargo: '—', currency: 'USD', amount: '50', unit: 'per wagon' },
],
surcharges: [],
isEmpty: false,
currencyLabel: 'USD',
},
signatures: [],
canSignCustomer: false,
canSignStaff: false,
@@ -151,11 +162,17 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance',
order: 1,
},
{
id: 'pricing',
title: 'Contract Price and Payment Terms',
body: 'Rates are set out in the Rate Schedule below.\nPayments 100% in advance.',
order: 2,
},
{
id: 'duration',
title: 'Duration',
body: 'Valid until August 31, {{contractYear}}.',
order: 2,
order: 3,
},
],
},
@@ -175,6 +192,16 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
expect(html).toContain('#1b9e7a');
});
it('renders the live rate schedule lane under the pricing article', () => {
const html = renderer.render(dynamicView());
expect(html).toContain('Rate Schedule');
// Base freight lane pulled from the rate config
expect(html).toContain('Nagad → Galaan Multipurpose Port');
expect(html).toContain('USD 100 per wagon');
// Additional-service group
expect(html).toContain('First-mile pickup by truck');
});
it('keeps the generic layout when no dynamic template is attached', () => {
const view = dynamicView();
delete view.dynamicTemplate;

View File

@@ -0,0 +1,98 @@
import { ContractRateScheduleBuilder } from './contract-rate-schedule.builder';
import { Rate } from '../modules/rule-engine/entities/rate.entity';
/** Minimal Rate factory for the builder unit tests. */
function rate(partial: Partial<Rate>): Rate {
return {
trigger: 'ALWAYS',
appliesTo: 'CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 200,
rateUnit: 'PER_CONTAINER',
...partial,
} as Rate;
}
describe('ContractRateScheduleBuilder', () => {
const LIVE: Rate[] = [
rate({
appliesTo: 'CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'CONTAINER_IMPORT',
rateValue: 200,
rateUnit: 'PER_CONTAINER',
originYard: { label: 'Negad' } as never,
destinationYard: { label: 'Mojo Dry Port' } as never,
containerType: { label: '40ft GP' } as never,
}),
rate({
appliesTo: 'CONTAINER',
tradeDirection: 'EXPORT', // wrong direction — must be filtered out for import
rateType: 'CONTAINER_EXPORT',
rateValue: 819,
originYard: { label: 'GMP' } as never,
destinationYard: { label: 'SGTD' } as never,
}),
rate({
appliesTo: 'BULK', // wrong freight — filtered out for a container contract
tradeDirection: 'IMPORT',
rateType: 'BULK_IMPORT',
rateUnit: 'PER_WAGON',
rateValue: 100,
}),
rate({
appliesTo: 'FIRST_MILE',
trigger: 'ALWAYS',
tradeDirection: null,
rateUnit: 'PER_CONTAINER',
rateValue: 50,
}),
rate({
appliesTo: 'OTHER',
trigger: 'CUSTOMS_CLEARANCE',
tradeDirection: null,
rateType: 'CUSTOMS_CLEARANCE',
rateUnit: 'FLAT',
rateValue: 120,
}),
];
const build = (dir: 'IMP' | 'EXP' | 'DOM', freight: 'CON' | 'BULK') => {
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue(LIVE) };
return new ContractRateScheduleBuilder(service as never).build(dir, freight);
};
it('shows only import container lanes for an import container contract', async () => {
const s = await build('IMP', 'CON');
expect(s.freightLanes).toHaveLength(1);
expect(s.freightLanes[0]).toMatchObject({
route: 'Negad → Mojo Dry Port',
cargo: '40ft GP',
currency: 'USD',
amount: '200',
unit: 'per container',
});
});
it('always lists route-agnostic services and surcharges', async () => {
const s = await build('IMP', 'CON');
expect(s.additionalServices).toHaveLength(1);
expect(s.additionalServices[0].route).toBe('First-mile pickup by truck');
expect(s.surcharges).toHaveLength(1);
expect(s.surcharges[0].route).toBe('Customs clearance service');
});
it('excludes container lanes from a bulk contract', async () => {
const s = await build('IMP', 'BULK');
expect(s.freightLanes).toHaveLength(1);
expect(s.freightLanes[0]).toMatchObject({ amount: '100', unit: 'per wagon' });
});
it('flags an empty schedule when nothing priced matches', async () => {
const service = { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) };
const s = await new ContractRateScheduleBuilder(service as never).build('DOM', 'CON');
expect(s.isEmpty).toBe(true);
});
});

View File

@@ -0,0 +1,226 @@
import { Injectable } from '@nestjs/common';
import { RatesService } from '../modules/rule-engine/services/rates.service';
import { Rate } from '../modules/rule-engine/entities/rate.entity';
import {
ContractDirection,
ContractFreight,
} from './contract-template.types';
/** One priced line in the contract's rate schedule. */
export interface RateScheduleRow {
/** "Negad → Mojo Dry Port" for base freight, service name otherwise. */
route: string;
/** "40ft GP", "Wheat", or "—" when the rate is not scoped to a type. */
cargo: string;
currency: string;
/** Pre-formatted amount, e.g. "200" (grouped, no trailing zeros). */
amount: string;
/** Human unit, e.g. "per container", "per wagon", "per ton". */
unit: string;
}
/**
* The origin → destination rate schedule shown in a generated contract's
* pricing article. Grouped so the reader sees rail freight lanes first, then
* pickup/delivery legs, then trigger-based surcharges and demurrage.
*/
export interface RateSchedule {
/** Base rail freight lanes matching this contract's direction + freight. */
freightLanes: RateScheduleRow[];
/** First-mile / last-mile truck legs (route-agnostic). */
additionalServices: RateScheduleRow[];
/** Hazard, reefer, overweight, demurrage, customs, etc. */
surcharges: RateScheduleRow[];
/** True when every group is empty — the template falls back to prose. */
isEmpty: boolean;
/** Currencies present across the schedule, e.g. "USD" or "USD, ETB". */
currencyLabel: string;
}
const UNIT_LABELS: Record<string, string> = {
PER_WAGON: 'per wagon',
PER_TON: 'per ton',
PER_CONTAINER: 'per container',
PER_KM: 'per km',
PER_INVOICE: 'per invoice',
FLAT: 'flat',
};
const SERVICE_ROUTE_LABELS: Partial<Record<Rate['appliesTo'], string>> = {
FIRST_MILE: 'First-mile pickup by truck',
LAST_MILE: 'Last-mile delivery by truck',
};
/** Friendly wording for the trigger-based charges shown in the surcharge group. */
const TRIGGER_ROUTE_LABELS: Partial<Record<Rate['trigger'], string>> = {
HAZARDOUS: 'Hazardous cargo surcharge',
OVERWEIGHT: 'Overweight surcharge',
REEFER: 'Reefer (refrigerated) surcharge',
WITH_RETURN: 'Empty-container return service',
SHIPPING_LINE: 'Shipping line handling',
CONSOLIDATION: 'Container consolidation (extra document)',
LASHING: 'Cargo lashing and securing',
CANCELLATION: 'Booking cancellation fee',
DEMURRAGE: 'Demurrage / wagon detention',
PIL_EXTRA_FEE: 'PIL shipping line extra fee',
CUSTOMS_CLEARANCE: 'Customs clearance service',
};
@Injectable()
export class ContractRateScheduleBuilder {
constructor(private readonly ratesService: RatesService) {}
/**
* Build the rate schedule for a contract of the given direction + freight.
* Base-freight lanes are filtered to the matching trade direction / freight
* kind so an import container contract shows import container lanes only;
* additional services and surcharges are route-agnostic and always shown.
*/
async build(
direction: ContractDirection,
freight: ContractFreight,
): Promise<RateSchedule> {
const rates = await this.ratesService.findLiveRatesDetailed();
const freightLanes: RateScheduleRow[] = [];
const additionalServices: RateScheduleRow[] = [];
const surcharges: RateScheduleRow[] = [];
for (const rate of rates) {
if (this.isBaseFreight(rate)) {
if (this.baseFreightMatches(rate, direction, freight)) {
freightLanes.push(this.laneRow(rate));
}
continue;
}
if (rate.appliesTo === 'FIRST_MILE' || rate.appliesTo === 'LAST_MILE') {
additionalServices.push(this.serviceRow(rate));
continue;
}
// Everything left is a trigger-based charge (surcharge / demurrage / customs).
surcharges.push(this.surchargeRow(rate));
}
const currencyLabel = this.currencyLabel([
...freightLanes,
...additionalServices,
...surcharges,
]);
return {
freightLanes,
additionalServices,
surcharges,
isEmpty:
freightLanes.length === 0 &&
additionalServices.length === 0 &&
surcharges.length === 0,
currencyLabel,
};
}
private isBaseFreight(rate: Rate): boolean {
return (
rate.trigger === 'ALWAYS' &&
(rate.appliesTo === 'BULK' ||
rate.appliesTo === 'CONTAINER' ||
rate.appliesTo === 'INTERCITY')
);
}
private baseFreightMatches(
rate: Rate,
direction: ContractDirection,
freight: ContractFreight,
): boolean {
// Domestic contracts price off intercity rates; the freight kind is carried
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
if (direction === 'DOM') {
if (rate.appliesTo !== 'INTERCITY') return false;
return freight === 'BULK'
? rate.rateType === 'INTERCITY_BULK'
: rate.rateType === 'INTERCITY_CONTAINER';
}
// Import / export price off BULK or CONTAINER rates matching the direction.
const wantAppliesTo = freight === 'BULK' ? 'BULK' : 'CONTAINER';
if (rate.appliesTo !== wantAppliesTo) return false;
const wantDirection = direction === 'IMP' ? 'IMPORT' : 'EXPORT';
return rate.tradeDirection === wantDirection;
}
private laneRow(rate: Rate): RateScheduleRow {
const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—';
const destination =
rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—';
return {
route: `${origin}${destination}`,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
private serviceRow(rate: Rate): RateScheduleRow {
return {
route: SERVICE_ROUTE_LABELS[rate.appliesTo] ?? rate.appliesTo,
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
private surchargeRow(rate: Rate): RateScheduleRow {
return {
route: TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger),
cargo: this.cargoLabel(rate),
currency: rate.currency,
amount: this.formatAmount(rate.rateValue),
unit: this.unitLabel(rate.rateUnit),
};
}
/** The type a rate is scoped to (container/cargo), or a dash when unscoped. */
private cargoLabel(rate: Rate): string {
return (
rate.containerType?.label ??
rate.containerType?.code ??
rate.cargoType?.cargoTypeName ??
'—'
);
}
private unitLabel(unit: Rate['rateUnit']): string {
return UNIT_LABELS[unit] ?? unit.toLowerCase().replace(/_/g, ' ');
}
/** Group thousands and drop the DB's trailing zeros: "200.0000" → "200". */
private formatAmount(value: number | string): string {
const num = Number(value);
if (!Number.isFinite(num)) return String(value);
return num.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
private currencyLabel(rows: RateScheduleRow[]): string {
const seen: string[] = [];
for (const row of rows) {
if (!seen.includes(row.currency)) seen.push(row.currency);
}
return seen.join(', ') || 'USD';
}
private titleCase(value: string): string {
return value
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
}

View File

@@ -58,6 +58,15 @@ describe('ContractRendererService', () => {
destinationLabel: 'Modjo',
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
},
rateSchedule: {
freightLanes: [
{ route: 'SGTD → Modjo', cargo: '40ft GP', currency: 'USD', amount: '200', unit: 'per container' },
],
additionalServices: [],
surcharges: [],
isEmpty: false,
currencyLabel: 'USD',
},
signatures: [],
canSignCustomer: true,
canSignStaff: false,

View File

@@ -57,6 +57,7 @@ export class ContractRendererService implements OnModuleInit {
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((article, index) => ({
number: index + 1,
id: article.id,
title: interpolateTemplateText(article.title, view),
...parseArticleBody(interpolateTemplateText(article.body, view)),
}));

View File

@@ -7,6 +7,7 @@ import {
ContractSignerRole,
} from '../modules/bookings/entities/booking-contract-signature.entity';
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder';
import { ContractTemplateResolver } from './contract-template.resolver';
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
@@ -74,6 +75,12 @@ export interface ContractViewModel {
lastMileDeliveryAddress: string;
};
pricing: PricingSchedule;
/**
* The live origin → destination rate schedule (base freight lanes + services
* + surcharges) matching this contract's direction and freight kind. Drives
* the pricing article's rate table so the contract mirrors the rate config.
*/
rateSchedule: RateSchedule;
signatures: ContractSignatureView[];
canSignCustomer: boolean;
canSignStaff: boolean;
@@ -89,6 +96,7 @@ export class ContractViewModelBuilder {
private readonly bookingsRepository: BookingsRepository,
private readonly templateResolver: ContractTemplateResolver,
private readonly pricingBuilder: ContractPricingScheduleBuilder,
private readonly rateScheduleBuilder: ContractRateScheduleBuilder,
) {}
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
@@ -101,6 +109,10 @@ export class ContractViewModelBuilder {
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const template = getTemplateMeta(templateKey);
const pricing = await this.pricingBuilder.build(booking);
const rateSchedule = await this.rateScheduleBuilder.build(
template.direction,
template.freight,
);
const signatures = await this.loadSignatures(bookingId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
@@ -143,6 +155,7 @@ export class ContractViewModelBuilder {
},
schedule: this.buildSchedule(booking),
pricing,
rateSchedule,
signatures,
canSignCustomer:
booking.status === 'CONTRACT_READY' && !hasCustomer,

View File

@@ -25,25 +25,9 @@
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
{{/if}}
{{#if pricing.unitRates}}
<h3>Unit Rate Schedule</h3>
<p>
The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting
totals are determined per shipment at booking time; no total contract value is fixed at this stage.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{#unless rateSchedule.isEmpty}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{else}}
<h3>Charges</h3>
<table class="schedule">
@@ -76,7 +60,7 @@
</tr>
</tbody>
</table>
{{/if}}
{{/unless}}
<h3>Terms of payment</h3>
<p>
Unless otherwise agreed in writing, the Client shall settle the contract value in

View File

@@ -20,5 +20,9 @@
{{/each}}
</ol>
{{/if}}
{{#if (eq id "pricing")}}
<h3>Rate Schedule</h3>
{{> rate_schedule}}
{{/if}}
</section>
{{/each}}

View File

@@ -0,0 +1,55 @@
{{#if rateSchedule.isEmpty}}
<p class="muted-note">
No published rate schedule is currently on file for this corridor. Applicable charges will be quoted
by the Service Provider per shipment in accordance with the prevailing EDR tariff.
</p>
{{else}}
<p>
The charges below are the current published railway tariff for this contract's trade direction and
freight type, expressed as unit prices per origin → destination lane. Quantities and the resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr>
<th>Route / Service</th>
<th>Cargo / Equipment</th>
<th>Unit price</th>
</tr>
</thead>
<tbody>
{{#if rateSchedule.freightLanes.length}}
<tr><th colspan="3">Railway Freight — Origin → Destination</th></tr>
{{#each rateSchedule.freightLanes}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.additionalServices.length}}
<tr><th colspan="3">Additional Services</th></tr>
{{#each rateSchedule.additionalServices}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
{{#if rateSchedule.surcharges.length}}
<tr><th colspan="3">Surcharges, Demurrage &amp; Fees</th></tr>
{{#each rateSchedule.surcharges}}
<tr>
<td>{{route}}</td>
<td>{{cargo}}</td>
<td>{{currency}} {{amount}} {{unit}}</td>
</tr>
{{/each}}
{{/if}}
</tbody>
</table>
{{/if}}

View File

@@ -134,26 +134,8 @@
</tbody>
</table>
{{#if pricing.unitRates.length}}
<h3>Agreed Unit Rates</h3>
<p class="muted-note">
The rates below are the frozen unit prices applicable to this contract. Quantities and resulting
totals are determined per shipment at booking time.
</p>
<table class="schedule">
<thead>
<tr><th>Item</th><th>Unit price</th></tr>
</thead>
<tbody>
{{#each pricing.unitRates}}
<tr>
<td>{{label}}</td>
<td>{{currency}} {{unitPrice}} / {{unit}}</td>
</tr>
{{/each}}
</tbody>
</table>
{{/if}}
<h3>Published Rate Schedule</h3>
{{> rate_schedule}}
</section>
{{!-- ────────────────────────── Signatures ───────────────────────────── --}}

View File

@@ -33,6 +33,13 @@ async function bootstrap() {
"delegator-position-id",
"current-project-id",
"current-position-id",
// x-prefixed variants sent by the user-management / record-management
// frontend modules (same values, different naming convention)
"x-organization-unit-id",
"x-delegator-id",
"x-delegator-position-id",
"x-current-project-id",
"x-current-position-id",
],
exposedHeaders: ["Content-Disposition"],
maxAge: 86400, // cache preflight for 24h to cut chatter in dev

View File

@@ -0,0 +1,66 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Configured rail distance between two yards (Configuration → Yard Distances).
* Route creation resolves each segment's km from here (symmetric lookup:
* one A↔B row serves both directions) instead of accepting free-text km,
* and snapshots the value onto route_milestones.distance_km.
*
* Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair
* can be re-created.
*/
export class CreateYardDistances2060000000000 implements MigrationInterface {
name = 'CreateYardDistances2060000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_distances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
from_yard_id uuid NOT NULL REFERENCES freight.yards(id),
to_yard_id uuid NOT NULL REFERENCES freight.yards(id),
distance_km numeric(10,2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard
ON freight.yard_distances (from_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard
ON freight.yard_distances (to_yard_id);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair
ON freight.yard_distances (from_yard_id, to_yard_id)
WHERE deleted_at IS NULL;
`);
// Backfill from segments already stored on existing routes so editing them
// does not immediately fail the "pair not configured" check. One row per
// unordered pair; where routes disagree the longest segment wins.
await queryRunner.query(`
INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km)
SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id))
prev_yard_id, yard_id, distance_km
FROM (
SELECT
yard_id,
distance_km,
LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id
FROM freight.route_milestones
WHERE deleted_at IS NULL
) segments
WHERE prev_yard_id IS NOT NULL
AND distance_km IS NOT NULL
AND distance_km > 0
ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC
ON CONFLICT DO NOTHING;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`);
}
}

View File

@@ -44,13 +44,13 @@ export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInter
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
// Wagon.wagonNumber declares `unique: true`, but some environments never got
// the constraint. Repair it here — the table is empty at this point, so the
// index build cannot fail on pre-existing duplicates.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
ON freight.wagons (wagon_number);
`);
// Deliberately does NOT create a unique index on wagon_number. It once did,
// to satisfy an ON CONFLICT clause that no longer exists (the DELETE above
// makes collisions impossible). Recreating the plain index here would undo
// WagonNumberPartialUnique2280000000000, which replaces it with a PARTIAL
// unique index so soft-deleted wagons stop reserving their number — this
// seeder is run directly by scripts/seed-edr-wagons.ts, which would
// otherwise resurrect the plain index on an already-migrated database.
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form.
*
* Nullable with no default: a wagon is not on a run until an operator says so.
* Mirrors the width of trains.export_train_number / trains.import_train_number
* (varchar 20) so the two stay comparable.
*/
export class AddWagonTrainNumbers2270000000000 implements MigrationInterface {
name = 'AddWagonTrainNumbers2270000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
ADD COLUMN IF NOT EXISTS export_train_number varchar(20),
ADD COLUMN IF NOT EXISTS import_train_number varchar(20);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagons
DROP COLUMN IF EXISTS export_train_number,
DROP COLUMN IF EXISTS import_train_number;
`);
}
}

View File

@@ -0,0 +1,206 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Assign EDR export/import run numbers to the wagon fleet.
*
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
* wagon with NULL run numbers — so this must stay later in timestamp order.
*
* Source data below is the operator-supplied roster, kept verbatim rather than
* pre-resolved so its quirks stay visible:
* - ER0697 is listed twice under run 8101 (deduped here -> 49, not 50).
* - Four wagons are claimed by two runs each. A wagon holds a single run, so
* FIRST-LISTED WINS, which is why four runs land one short of their listed
* count:
* ER0484 8301 over 8401
* ER0451 8401 over 8701
* ER0887 8701 over 9001
* ER0936 8801 over 8901
*
* Wagons outside this roster (PW2 ER0001-0220 and ER0941-1100) keep NULL runs.
*/
/** Odd EXPORT run (Ethiopia -> Djibouti) -> the wagons rostered to it. */
const RUN_WAGONS: Record<string, string[]> = {
'8001': [
'ER0744', 'ER0734', 'ER0791', 'ER0885', 'ER0410', 'ER0901',
'ER0692', 'ER0784', 'ER0663', 'ER0547', 'ER0635', 'ER0840',
'ER0660', 'ER0541', 'ER0850', 'ER0764', 'ER0786', 'ER0694',
'ER0656', 'ER0432', 'ER0666', 'ER0879', 'ER0724', 'ER0868',
'ER0835', 'ER0650', 'ER0926', 'ER0915', 'ER0858', 'ER0826',
'ER0474', 'ER0539', 'ER0419', 'ER0695', 'ER0462', 'ER0825',
'ER0820', 'ER0790', 'ER0905', 'ER0557', 'ER0712', 'ER0782',
'ER0816', 'ER0447', 'ER0674', 'ER0424', 'ER0544', 'ER0519',
'ER0479', 'ER0440',
],
'8101': [
'ER0458', 'ER0600', 'ER0521', 'ER0559', 'ER0846', 'ER0459',
'ER0863', 'ER0925', 'ER0746', 'ER0821', 'ER0914', 'ER0768',
'ER0676', 'ER0470', 'ER0697', 'ER0697', 'ER0923', 'ER0937',
'ER0431', 'ER0412', 'ER0254', 'ER0555', 'ER0527', 'ER0590',
'ER0480', 'ER0723', 'ER0316', 'ER0800', 'ER0648', 'ER0435',
'ER0844', 'ER0939', 'ER0747', 'ER0654', 'ER0752', 'ER0633',
'ER0725', 'ER0567', 'ER0838', 'ER0920', 'ER0843', 'ER0520',
'ER0646', 'ER0407', 'ER0515', 'ER0760', 'ER0703', 'ER0880',
'ER0422', 'ER0852',
],
'8201': [
'ER0322', 'ER0314', 'ER0274', 'ER0514', 'ER0505', 'ER0618',
'ER0812', 'ER0776', 'ER0698', 'ER0662', 'ER0888', 'ER0625',
'ER0568', 'ER0596', 'ER0918', 'ER0524', 'ER0684', 'ER0231',
'ER0907', 'ER0445', 'ER0839', 'ER0430', 'ER0799', 'ER0464',
'ER0491', 'ER0833', 'ER0855', 'ER0571', 'ER0452', 'ER0733',
'ER0606', 'ER0822', 'ER0845', 'ER0771', 'ER0542', 'ER0588',
'ER0443', 'ER0585', 'ER0624', 'ER0538', 'ER0642', 'ER0928',
'ER0411', 'ER0794', 'ER0564', 'ER0906', 'ER0348', 'ER0236',
'ER0933', 'ER0456',
],
'8301': [
'ER0264', 'ER0691', 'ER0562', 'ER0686', 'ER0881', 'ER0780',
'ER0400', 'ER0420', 'ER0475', 'ER0425', 'ER0396', 'ER0818',
'ER0537', 'ER0917', 'ER0421', 'ER0766', 'ER0728', 'ER0485',
'ER0830', 'ER0804', 'ER0935', 'ER0898', 'ER0577', 'ER0762',
'ER0558', 'ER0612', 'ER0484', 'ER0566', 'ER0876', 'ER0528',
'ER0292', 'ER0630', 'ER0761', 'ER0849', 'ER0578', 'ER0232',
'ER0673', 'ER0870', 'ER0575', 'ER0250', 'ER0599', 'ER0622',
'ER0801', 'ER0806', 'ER0594', 'ER0831', 'ER0513',
],
'8401': [
'ER0616', 'ER0730', 'ER0415', 'ER0522', 'ER0454', 'ER0758',
'ER0715', 'ER0658', 'ER0602', 'ER0649', 'ER0540', 'ER0434',
'ER0678', 'ER0550', 'ER0402', 'ER0636', 'ER0500', 'ER0740',
'ER0664', 'ER0397', 'ER0565', 'ER0704', 'ER0720', 'ER0787',
'ER0884', 'ER0573', 'ER0755', 'ER0392', 'ER0739', 'ER0530',
'ER0437', 'ER0484', 'ER0653', 'ER0502', 'ER0615', 'ER0563',
'ER0641', 'ER0391', 'ER0789', 'ER0451', 'ER0819', 'ER0442',
'ER0798', 'ER0729', 'ER0772', 'ER0940', 'ER0682', 'ER0614',
'ER0561', 'ER0393',
],
'8501': [
'ER0807', 'ER0289', 'ER0587', 'ER0902', 'ER0877', 'ER0748',
'ER0837', 'ER0408', 'ER0307', 'ER0759', 'ER0847', 'ER0433',
'ER0498', 'ER0492', 'ER0735', 'ER0503', 'ER0461', 'ER0508',
'ER0243', 'ER0583', 'ER0924', 'ER0395', 'ER0707', 'ER0572',
'ER0536', 'ER0796', 'ER0929', 'ER0713', 'ER0603', 'ER0814',
'ER0756', 'ER0398', 'ER0853', 'ER0276', 'ER0405', 'ER0418',
'ER0517', 'ER0919', 'ER0781', 'ER0516', 'ER0417', 'ER0702',
'ER0857', 'ER0486', 'ER0637', 'ER0736', 'ER0859', 'ER0483',
'ER0824', 'ER0640', 'ER0714',
],
'8601': [
'ER0455', 'ER0930', 'ER0293', 'ER0294', 'ER0677', 'ER0808',
'ER0785', 'ER0628', 'ER0545', 'ER0551', 'ER0644', 'ER0922',
'ER0670', 'ER0864', 'ER0629', 'ER0306', 'ER0494', 'ER0496',
'ER0679', 'ER0874', 'ER0921', 'ER0910', 'ER0621', 'ER0667',
'ER0262', 'ER0774', 'ER0488', 'ER0300', 'ER0234', 'ER0711',
'ER0605', 'ER0897', 'ER0841', 'ER0778', 'ER0769', 'ER0487',
'ER0556', 'ER0526', 'ER0795', 'ER0268', 'ER0266', 'ER0257',
],
'8701': [
'ER0263', 'ER0661', 'ER0282', 'ER0394', 'ER0423', 'ER0665',
'ER0598', 'ER0909', 'ER0481', 'ER0854', 'ER0471', 'ER0582',
'ER0671', 'ER0466', 'ER0788', 'ER0934', 'ER0683', 'ER0680',
'ER0890', 'ER0531', 'ER0647', 'ER0823', 'ER0608', 'ER0900',
'ER0467', 'ER0607', 'ER0554', 'ER0233', 'ER0911', 'ER0726',
'ER0675', 'ER0291', 'ER0313', 'ER0619', 'ER0775', 'ER0705',
'ER0548', 'ER0891', 'ER0560', 'ER0904', 'ER0429', 'ER0655',
'ER0224', 'ER0700', 'ER0797', 'ER0706', 'ER0533', 'ER0861',
'ER0580', 'ER0449', 'ER0409', 'ER0613', 'ER0645', 'ER0315',
'ER0718', 'ER0553', 'ER0444', 'ER0593', 'ER0499', 'ER0693',
'ER0525', 'ER0451', 'ER0634', 'ER0689', 'ER0878', 'ER0518',
'ER0887',
],
'8801': [
'ER0811', 'ER0652', 'ER0889', 'ER0886', 'ER0936', 'ER0476',
'ER0832', 'ER0626', 'ER0669', 'ER0404', 'ER0546', 'ER0501',
'ER0894', 'ER0460', 'ER0805', 'ER0465', 'ER0717', 'ER0601',
'ER0751', 'ER0777', 'ER0504', 'ER0749', 'ER0827', 'ER0896',
'ER0903', 'ER0591', 'ER0436', 'ER0552', 'ER0716', 'ER0895',
'ER0463', 'ER0809', 'ER0473', 'ER0883', 'ER0569', 'ER0610',
'ER0275', 'ER0333', 'ER0344', 'ER0469',
],
'8901': [
'ER0913', 'ER0310', 'ER0873', 'ER0448', 'ER0763', 'ER0441',
'ER0936', 'ER0767', 'ER0416', 'ER0413', 'ER0589', 'ER0453',
'ER0507', 'ER0287', 'ER0414', 'ER0406', 'ER0584', 'ER0866',
'ER0893', 'ER0627', 'ER0227', 'ER0403', 'ER0428', 'ER0908',
'ER0349', 'ER0221', 'ER0271', 'ER0659', 'ER0765', 'ER0478',
'ER0511', 'ER0506', 'ER0743', 'ER0512', 'ER0916', 'ER0497',
'ER0643', 'ER0638', 'ER0468', 'ER0597',
],
'9001': [
'ER0446', 'ER0802', 'ER0570', 'ER0836', 'ER0576', 'ER0672',
'ER0631', 'ER0490', 'ER0851', 'ER0450', 'ER0872', 'ER0912',
'ER0815', 'ER0882', 'ER0738', 'ER0899', 'ER0620', 'ER0399',
'ER0685', 'ER0477', 'ER0842', 'ER0529', 'ER0617', 'ER0865',
'ER0754', 'ER0737', 'ER0753', 'ER0732', 'ER0623', 'ER0574',
'ER0803', 'ER0651', 'ER0489', 'ER0668', 'ER0741', 'ER0699',
'ER0592', 'ER0225', 'ER0229', 'ER0298', 'ER0270', 'ER0259',
'ER0337', 'ER0770', 'ER0327', 'ER0251', 'ER0285', 'ER0927',
'ER0810', 'ER0681', 'ER0887',
],
};
/**
* Even IMPORT run (Djibouti -> Ethiopia) for each export run. Listed rather
* than computed as export+1 so a run that ever breaks the convention stays
* correct. Run numbers are always 4 digits (8401, never 84001).
*/
const IMPORT_RUN: Record<string, string> = {
'8001': '8002',
'8101': '8102',
'8201': '8202',
'8301': '8302',
'8401': '8402',
'8501': '8502',
'8601': '8602',
'8701': '8702',
'8801': '8802',
'8901': '8902',
'9001': '9002',
};
export class SeedWagonRunNumbers2280000000000 implements MigrationInterface {
name = 'SeedWagonRunNumbers2280000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Idempotent: clear the roster's runs first so a re-run cannot leave a
// wagon on a run it was since moved off of.
await queryRunner.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
const claimed = new Set<string>();
for (const [exportRun, wagons] of Object.entries(RUN_WAGONS)) {
const importRun = IMPORT_RUN[exportRun];
if (!importRun) throw new Error(`import_run_missing:${exportRun}`);
// First-listed wins — skip any wagon an earlier run already claimed.
const fresh = wagons.filter((w) => !claimed.has(w));
fresh.forEach((w) => claimed.add(w));
if (!fresh.length) continue;
await queryRunner.query(
`
UPDATE freight.wagons
SET export_train_number = $1,
import_train_number = $2,
updated_at = now()
WHERE wagon_number = ANY($3::text[]);
`,
[exportRun, importRun, fresh],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.wagons
SET export_train_number = NULL, import_train_number = NULL
WHERE export_train_number IS NOT NULL;
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* container_types.wagons_per_unit is no longer stored: the wagon fraction is
* derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per
* wagon; see rule-engine/container-type.util.ts). The stored value duplicated
* that rule and could silently drift from it.
*/
export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface {
name = 'DropContainerWagonsPerUnit2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.container_types
ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2);
`);
// Backfill from the same size rule the code now derives from.
await queryRunner.query(`
UPDATE freight.container_types
SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END;
`);
}
}

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Stand the whole wagon fleet in Doraleh.
*
* Runs AFTER SeedEdrWagonFleetErNumbering2260000000000, which recreates every
* wagon with a NULL yard — so this must stay later in timestamp order.
*
* A wagon with no yard cannot be coupled to a train (the train builder only
* offers AVAILABLE wagons standing in the train's own yard), which left the
* seeded fleet unusable. Doraleh is the Djibouti-side port yard the import runs
* originate from.
*
* The yard is created when absent: environments disagree about which yards
* exist, so this cannot assume one is there.
*/
const YARD_CODE = 'DORALEH';
export class SeedWagonYardDoraleh2290000000000 implements MigrationInterface {
name = 'SeedWagonYardDoraleh2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Ensure the yard exists and is usable. Deliberately does NOT overwrite an
// existing label/country — a deployment that already calls this yard
// something else keeps its own naming.
await queryRunner.query(
`
INSERT INTO freight.yards (code, label, country, is_active, display_order)
VALUES ($1, 'Doraleh', 'Djibouti', true, 12)
ON CONFLICT (code) DO UPDATE SET
is_active = true,
deleted_at = NULL,
updated_at = now();
`,
[YARD_CODE],
);
const [yard] = await queryRunner.query(
`SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
[YARD_CODE],
);
if (!yard?.id) {
throw new Error(`yard_missing:${YARD_CODE}`);
}
// Whole fleet — a wagon already coupled to a built train follows the train,
// so leave those where they stand.
await queryRunner.query(
`
UPDATE freight.wagons
SET current_yard_id = $1::uuid,
updated_at = now()
WHERE train_id IS NULL;
`,
[yard.id],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Back to the state SeedEdrWagonFleetErNumbering leaves them in.
await queryRunner.query(`
UPDATE freight.wagons
SET current_yard_id = NULL
WHERE train_id IS NULL;
`);
}
}

View File

@@ -0,0 +1,85 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Intercity (DOMESTIC) cargo is loaded at its origin yard and unloaded at its
* destination yard, but only some yards have the equipment to do it. EDR's
* load/unload facilities are Indode, Sebeta, Modjo, Adama, Dire Dawa and Negad —
* and the set grows, so it must be data, not a constant.
*
* `yards.has_facility` marks a yard as a load/unload point; `yard_facilities`
* holds what that facility can do. Only a facility with `has_warehouse` (Indode
* today) stores cargo, and therefore accrues storage/demurrage — the rest just
* move it on and off the train.
*
* `facility_handling_events` records each load/unload and carries its GRN.
* warehouse_inventory can't do that job: its warehouse/yard/zone are NOT NULL, so
* a facility with no warehouse could never have a row. `inventory_id` links to the
* storage record when the facility does have a warehouse.
*/
export class YardFacilities2290000000000 implements MigrationInterface {
name = 'YardFacilities2290000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yards
ADD COLUMN IF NOT EXISTS has_facility boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_facilities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
yard_id uuid NOT NULL REFERENCES freight.yards(id) ON DELETE CASCADE,
has_warehouse boolean NOT NULL DEFAULT false,
equipment_notes text NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
// One facility record per yard.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_yard_facility_yard"
ON freight.yard_facilities (yard_id) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.facility_handling_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
yard_id uuid NOT NULL REFERENCES freight.yards(id),
train_schedule_id uuid NULL REFERENCES freight.train_schedules(id),
event_type varchar(10) NOT NULL,
grn_number varchar(60) NULL,
quantity numeric(14, 3) NULL,
weight_tons numeric(14, 3) NULL,
inventory_id uuid NULL REFERENCES freight.warehouse_inventory(id),
performed_by varchar(120) NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_booking"
ON freight.facility_handling_events (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_yard"
ON freight.facility_handling_events (yard_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_facility_handling_events_grn"
ON freight.facility_handling_events (grn_number) WHERE grn_number IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.facility_handling_events`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_facilities`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP COLUMN IF EXISTS has_facility
`);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Approval workflow for edits to LIVE rates. A LIVE rate is what pricing
* charges, so it is never edited in place: the edit is filed here as PENDING
* and the live row keeps its value until an approver applies it.
*
* `payload` holds the changed fields only; `previous_values` snapshots what
* they were at submit time so the approver sees a real before→after diff.
*/
export class CreateRateChangeRequests2300000000000 implements MigrationInterface {
name = 'CreateRateChangeRequests2300000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.rate_change_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
rate_id uuid NOT NULL REFERENCES freight.rates (id),
payload jsonb NOT NULL,
previous_values jsonb NOT NULL,
status varchar(10) NOT NULL DEFAULT 'PENDING',
requested_by_user_id uuid NULL,
decided_by_user_id uuid NULL,
decided_at timestamptz NULL,
decision_note text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_rcr_status
ON freight.rate_change_requests (status)
`);
// At most one pending edit per rate — two racing requests would both pass
// validation and the second would silently overwrite the first on approval.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_rcr_one_pending_per_rate
ON freight.rate_change_requests (rate_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.rate_change_requests`);
}
}

View File

@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Repair for environments missing the GPS tracking tables.
*
* AddGpsTracking2000000000000 creates freight.gps_devices / gps_positions, but
* some databases have it RECORDED in public.migrations without the tables ever
* landing. TypeORM never re-runs a recorded migration, so those environments
* stay broken through any number of restarts — the GT06 listener accepts tracker
* packets on its TCP port regardless of schema state and fails per packet with
* `relation "freight.gps_devices" does not exist`, dropping position fixes.
*
* This re-issues the same DDL under a new name so it is applied afresh. Every
* statement is IF NOT EXISTS, so it is a no-op where the tables already exist
* and safe on every environment.
*
* Kept byte-identical to the original DDL on purpose: this must converge on the
* schema the entities expect, not a variant of it.
*/
export class RepairGpsTrackingTables2300000000000 implements MigrationInterface {
name = "RepairGpsTrackingTables2300000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
imei varchar(20) NOT NULL UNIQUE,
name varchar,
vehicle_id uuid REFERENCES freight.vehicles(id),
status varchar(16) NOT NULL DEFAULT 'REGISTERED',
last_seen_at timestamptz,
last_lat numeric(10,6),
last_lng numeric(10,6),
last_speed numeric(6,2),
last_course int,
last_fix_at timestamptz,
voltage_level int,
gsm_level int,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE"
ON freight.gps_devices (vehicle_id)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.gps_positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
device_id uuid NOT NULL,
imei varchar(20) NOT NULL,
vehicle_id uuid,
lat numeric(10,6) NOT NULL,
lng numeric(10,6) NOT NULL,
speed numeric(6,2) NOT NULL DEFAULT 0,
course int NOT NULL DEFAULT 0,
satellites int NOT NULL DEFAULT 0,
positioned boolean NOT NULL DEFAULT false,
gps_time timestamptz NOT NULL,
alarm int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME"
ON freight.gps_positions (device_id, gps_time)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME"
ON freight.gps_positions (vehicle_id, gps_time)
`);
}
public async down(): Promise<void> {
// No-op: dropping the tables would discard tracker history on environments
// where this migration was the one that created them. AddGpsTracking owns
// the teardown.
}
}

View File

@@ -0,0 +1,78 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Customer-support chat. A `support_conversations` row is the single ongoing
* thread with a company; `support_messages` are its text messages. There is no
* lifecycle column — a thread is opened by whichever side speaks first and
* stays open. Enum-like columns are varchar (no PG enum churn).
*
* The unique index on `company_id` is load-bearing, not just an optimization:
* the get-or-create path depends on it to settle concurrent first-messages.
* It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
* a fresh one.
*/
export class CreateSupportChat2310000000000 implements MigrationInterface {
name = "CreateSupportChat2310000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_conversations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_id uuid NOT NULL,
company_name varchar(200),
created_by_user_id uuid,
last_message_at timestamptz,
last_message_preview varchar(280),
last_message_author_role varchar(12),
customer_last_read_at timestamptz,
agent_last_read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
ON freight.support_conversations (company_id)
WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
ON freight.support_conversations (last_message_at)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.support_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id uuid NOT NULL,
author_user_id uuid NOT NULL,
author_role varchar(12) NOT NULL,
author_name varchar(200),
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
ON freight.support_messages (conversation_id, created_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS freight.support_conversations`,
);
}
}

View File

@@ -0,0 +1,140 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope base rail freight to a route (origin yard → destination yard).
*
* Until now a base-freight rate was keyed by direction + container/bulk scope
* only, so "container import" cost the same whether the box was railed to Dire
* Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which
* is what the business actually sells: `container import, Djibouti → Dire Dawa,
* 500 USD`.
*
* Existing base-freight rates predate the yard pair and cannot be backfilled —
* there is no way to know which route each was meant for. They are retired
* (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot
* and rate_change_requests hold FKs to them (RESTRICT) and those rows are price
* history. Retiring drops them out of pricing and the admin UI just the same;
* the yard-scoped replacements must be re-entered.
*
* Surcharges, first-mile and last-mile rates are untouched: they are not
* route-scoped and keep NULL yards.
*/
export class AddRateYardScope2320000000000 implements MigrationInterface {
name = 'AddRateYardScope2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── 1. Yard columns + FKs ──────────────────────────────────────────────
await queryRunner.query(`
ALTER TABLE freight.rates
ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL,
ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL;
`);
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_origin_yard_id"
FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "FK_rates_destination_yard_id"
FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id);
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`,
);
// ── 2. Retire route-less base freight ──────────────────────────────────
// Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE
// RESTRICT and those snapshots are what past bookings were charged.
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND "trigger" = 'ALWAYS'
AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY');
`);
// ── 3. Route is part of a rate's identity ──────────────────────────────
// Two rates may now share rateType + scope + unit as long as they price
// different legs, so the yard pair joins the uniqueness tuple.
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
// ── 4. Base freight must carry a route; nothing else may ───────────────
// Retired rows are exempt — they are the route-less rates step 2 just
// superseded, and they must stay readable for snapshot history.
await queryRunner.query(`
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// The retired rates are not un-superseded: which route each belonged to was
// never recorded, so reviving them would restore rates that price the wrong
// legs. Down only reverses the schema.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
ON freight.rates (
rate_type,
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
COALESCE(trade_direction, ''),
rate_unit
)
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`);
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`,
);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
DROP COLUMN IF EXISTS destination_yard_id,
DROP COLUMN IF EXISTS origin_yard_id;
`);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Let a support message carry files instead of text.
*
* No new table: chat attachments reuse the polymorphic `freight.files` record
* with `resource = 'support_message'` and `resource_id = <message id>`, the same
* way bookings/contracts/companies already store theirs.
*
* The only schema change is dropping NOT NULL from `support_messages.body`, so
* an attachment-only message can say "there is no text" rather than smuggling
* that fact through an empty string. DROP NOT NULL is a catalog-only change in
* Postgres — no table rewrite, no long lock — so this is safe on a live table.
*
* The partial index on (resource, resource_id) is what makes hydrating a page of
* messages one indexed lookup instead of a scan of every file row in the system.
*/
export class SupportChatAttachments2320000000000 implements MigrationInterface {
name = "SupportChatAttachments2320000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.support_messages
ALTER COLUMN body DROP NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP"
ON freight.files (resource, resource_id)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP"
`);
// Re-imposing NOT NULL would fail on any attachment-only message written
// while this migration was applied. Backfill those to '' first so the
// rollback is deterministic rather than dependent on production data.
await queryRunner.query(`
UPDATE freight.support_messages SET body = '' WHERE body IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.support_messages
ALTER COLUMN body SET NOT NULL
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* A facility handles what its equipment can handle. Containers need a reach
* stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs
* far less, so all five facilities load and unload it.
*
* Both default true — a facility handles everything unless someone says
* otherwise, which keeps existing rows working and makes the seeder the place
* where the real capability is stated.
*/
export class YardFacilityFreightTypes2320000000000 implements MigrationInterface {
name = 'YardFacilityFreightTypes2320000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.yard_facilities
DROP COLUMN IF EXISTS handles_container,
DROP COLUMN IF EXISTS handles_bulk
`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a global "booking close offset" — how long BEFORE departure a schedule's
* booking window shuts — configurable separately for import and export.
*
* When an offset is set, the window's close instant is `departure offset`
* (e.g. departure 17:00 with a 3-hour import offset closes at 14:00; departure
* Jul-10 16:00 with a 1-day export offset closes Jul-9 16:00). It caps the whole
* booking lifecycle: the first window close, every reopen cycle, and the export
* FCFS close all land at/at-or-before this cutoff instead of at departure.
*
* NULL / 0 preserves the previous behaviour exactly (import closes at
* open+duration clamped to departure; export closes at departure), so existing
* installs are unaffected until an offset is entered.
*
* `*_close_offset_minutes` on the global-rules singleton is the live config; the
* matching `rule_*_close_offset_minutes` snapshot on each schedule freezes it at
* creation so the batch board keeps drawing the window the customer was shown
* even after a later global-rules edit. Both are nullable with no backfill —
* absent means "no offset", the safe default.
*/
export class AddBookingCloseOffset2330000000000 implements MigrationInterface {
name = "AddBookingCloseOffset2330000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
ADD COLUMN IF NOT EXISTS import_close_offset_minutes integer,
ADD COLUMN IF NOT EXISTS export_close_offset_minutes integer;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS rule_import_close_offset_minutes integer,
ADD COLUMN IF NOT EXISTS rule_export_close_offset_minutes integer;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS rule_import_close_offset_minutes,
DROP COLUMN IF EXISTS rule_export_close_offset_minutes;
`);
await queryRunner.query(`
ALTER TABLE freight.train_scheduling_global_rules
DROP COLUMN IF EXISTS import_close_offset_minutes,
DROP COLUMN IF EXISTS export_close_offset_minutes;
`);
}
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add `has_lashing` to cargo types.
*
* When true, every booking of that cargo type incurs the flat LASHING
* surcharge (a rate with trigger = 'LASHING'). Defaults to false so existing
* cargo ships without the fee until the flag is turned on.
*/
export class AddCargoTypeHasLashing2340000000000 implements MigrationInterface {
name = "AddCargoTypeHasLashing2340000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS has_lashing boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.cargo_types
DROP COLUMN IF EXISTS has_lashing;
`);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add an opt-in "reverse wagon order" flag to a train schedule.
*
* When true, the built wagon plan is flipped at build time so the physically-last
* wagon sits at position 1. Only the order (sequence_no) changes — composition and
* booking allocations travel with their slot. The flag is frozen on the schedule
* at creation and re-applied every time the wagon plan is rebuilt, so the stored
* train order and the schedule order always match.
*
* Defaults to false; existing schedules keep their as-built order.
*/
export class AddReverseWagonOrder2340000000000 implements MigrationInterface {
name = "AddReverseWagonOrder2340000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS reverse_wagon_order boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS reverse_wagon_order;
`);
}
}

View File

@@ -0,0 +1,63 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Refresh the "pricing" article of each seeded contract template so it points
* at the live Rate Schedule instead of hardcoded price figures (USD 400/wagon,
* USD 919/40ft, …). The original CreateContractTemplates migration seeded the
* old prose with ON CONFLICT DO NOTHING, so those figures are frozen in the DB
* rows and would otherwise contradict the rate-config-driven schedule table now
* rendered under the pricing article.
*
* Only the article whose id = 'pricing' is touched, and only when its body
* still matches the originally-seeded prose — so any admin edit to the pricing
* article is left untouched. Idempotent: re-running is a no-op once refreshed.
*/
export class RefreshContractPricingArticles2350000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const pricing = seed.articles.find((article) => article.id === 'pricing');
if (!pricing) continue;
// jsonb_set the title + body of the element whose id = 'pricing', matched
// by array index. Guarded so admin-edited bodies are never overwritten.
await queryRunner.query(
`
UPDATE freight.contract_templates ct
SET articles = (
SELECT jsonb_agg(
CASE
WHEN elem->>'id' = 'pricing'
THEN elem || jsonb_build_object('title', $2::text, 'body', $3::text)
ELSE elem
END
)
FROM jsonb_array_elements(ct.articles) elem
)
WHERE ct.code = $1
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(ct.articles) e
WHERE e->>'id' = 'pricing'
AND e->>'body' LIKE ANY (ARRAY[
'%USD 59.4 per metric ton%',
'%USD 696 (six hundred ninety-six) per wagon%',
'%USD 400 (four hundred) per wagon%',
'%From SGTD to Dire Dawa dry port, the rate is USD 919%',
'%Railway transportation charges from GMP to SGTD: USD 819%',
'%prevailing EDR domestic container tariff, as set out in the commercial schedule%'
])
);
`,
[seed.code, pricing.title, pricing.body],
);
}
}
public async down(): Promise<void> {
// No-op: the refreshed pricing prose is the correct forward state; reverting
// to hardcoded figures would reintroduce the rate-schedule contradiction.
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Refresh the `pricing` article body of the six seeded contract templates to
* the live-rate-schedule wording. The per-lane figures (e.g. "USD 400 per
* wagon") are now rendered from the LIVE rate config instead of frozen prose,
* so any template whose pricing article still carries a hardcoded price token
* is rewritten to the current seed text.
*
* The guard `body ~ '(USD|ETB) [0-9]'` identifies the auto-seeded original
* prose (which always quoted a currency + figure) and matches neither an
* already-migrated body nor a hand-edited one that adopted the schedule
* wording — so admin edits are preserved. Idempotent: after the rewrite the
* price token is gone, so a re-run is a no-op. Fresh databases seed the new
* text directly (CreateContractTemplates imports the same seed), making this
* a targeted backfill for databases seeded before the seed changed.
*/
const HARDCODED_PRICE_TOKEN = '(USD|ETB) [0-9]';
export class RefreshContractPricingArticles2360000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
const pricing = seed.articles.find((a) => a.id === 'pricing');
if (!pricing) continue;
// Rewrite only the article whose id = 'pricing', in place, and only when
// its body still quotes a hardcoded currency figure. jsonb_agg keeps the
// rest of the article (id/title/order) and every other article intact.
await queryRunner.query(
`
UPDATE freight.contract_templates AS t
SET articles = (
SELECT jsonb_agg(
CASE
WHEN elem->>'id' = 'pricing'
THEN jsonb_set(elem, '{body}', to_jsonb($2::text), true)
ELSE elem
END
ORDER BY ord
)
FROM jsonb_array_elements(t.articles) WITH ORDINALITY AS a(elem, ord)
),
updated_at = now()
WHERE t.code = $1
AND EXISTS (
SELECT 1
FROM jsonb_array_elements(t.articles) AS x
WHERE x->>'id' = 'pricing'
AND x->>'body' ~ $3
);
`,
[seed.code, pricing.body, HARDCODED_PRICE_TOKEN],
);
}
}
/**
* Irreversible in practice — the original per-lane figures are not restored.
* A no-op down keeps the migration reversible-by-contract without
* resurrecting stale hardcoded prices.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-container handling opt-in: each physical container can now be marked
* hazardous / reefer / with-return individually, next to its VGM. The hazardous
* and reefer flags already existed on the unit row; only the return leg was
* missing, so a booking of 20 containers with 10 returning empty can bill the
* WITH_RETURN surcharge on 10 instead of all 20.
*
* Backfill: existing rows keep false. The line-level counts
* (booking_container.return_quantity etc.) stay authoritative for bookings made
* before this change — the rule engine falls back to them when no unit is flagged.
*/
export class AddContainerUnitReturnFlag2370000000000 implements MigrationInterface {
name = 'AddContainerUnitReturnFlag2370000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" ADD COLUMN IF NOT EXISTS "is_return" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."booking_container_units" DROP COLUMN IF EXISTS "is_return"`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* New built-train lifecycle status DEACTIVATED: staff park a train indefinitely
* (only allowed while it has no DRAFT/SCHEDULED/DISPATCHED schedule). Like
* UNDER_MAINTENANCE / OUT_OF_SERVICE it is staff-owned — the scheduler never
* overwrites it and refuses to schedule a deactivated train.
*
* Postgres cannot drop an enum value, so down() is a no-op.
*/
export class AddTrainDeactivatedStatus2380000000000 implements MigrationInterface {
name = 'AddTrainDeactivatedStatus2380000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "freight"."train_status" ADD VALUE IF NOT EXISTS 'DEACTIVATED'`,
);
}
public async down(): Promise<void> {
// Enum values cannot be removed in Postgres; leaving the label is harmless.
}
}

View File

@@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Admin-managed catalog of IMPORT run numbers (even, Djibouti → Ethiopia)
* selectable in the Train Builder. The paired EXPORT number is derived
* (import 1), so only the import side is configured. Seeded with the runs
* historically hardcoded in the backoffice's trainRuns constants; admins add
* new runs from the Dropdown Settings editor.
*/
export class SeedImportTrainNumbers2390000000000 implements MigrationInterface {
name = 'SeedImportTrainNumbers2390000000000';
private readonly code = 'import_train_numbers';
private readonly options: string[] = [
'8002',
'8102',
'8202',
'8302',
'8402',
'8502',
'8602',
'8702',
'8802',
'8902',
'9002',
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple, meta)
VALUES ($1, $2, $3, false, $4::jsonb)
RETURNING id;`,
[
this.code,
'Import train numbers',
'Even IMPORT run numbers (Djibouti → Ethiopia) selectable when building a train. The paired export number is derived automatically (import 1).',
JSON.stringify({ searchable: true, clearable: true }),
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const value = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, value, value, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard soft-delete now appends `@<epoch-ms>` to the unique code (SEBETA →
* SEBETA@1755612345678) so the name can be reused by a new yard while
* UQ_yards_code still spans soft-deleted rows. varchar(20) can't hold long
* codes plus the 14-char suffix, so widen to 40.
*/
export class WidenYardCodeForSoftDeleteSuffix2390000000000 implements MigrationInterface {
name = 'WidenYardCodeForSoftDeleteSuffix2390000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "freight"."yards" ALTER COLUMN "code" TYPE varchar(40)`,
);
}
public async down(): Promise<void> {
// Narrowing would fail on suffixed codes; keep 40.
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck exit weights for customer self-haul, mirroring what
* `last_mile_vehicle_assignments` already carries for EDR trucks.
*
* A bulk booking is hauled away truck by truck until no tonnage is left, and the
* EDR side enforces that by summing `net_weight_tons` of departed trucks. The
* customer side had no net and no tare — only `gross_weight_kg`, which nothing
* in the live flow ever wrote (the release flow updated the EDR table only). So
* a self-haul bulk booking could take unlimited trucks: hauled tonnage always
* summed to zero.
*
* `gross_weight_kg` is left alone but note it holds TONNES despite its name —
* the weighing UI is in tonnes throughout. The new columns are named for the
* unit they actually hold.
*/
export class AddCustomerTruckExitWeights2400000000000 implements MigrationInterface {
name = 'AddCustomerTruckExitWeights2400000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
ADD COLUMN IF NOT EXISTS tare_weight_tons numeric(14,3) NULL,
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14,3) NULL
`);
// Departed trucks are what the drawdown sums, so it reads this index.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_customer_truck_departed"
ON freight.customer_truck_assignments (booking_id, departed_at)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_customer_truck_departed"`,
);
await queryRunner.query(`
ALTER TABLE freight.customer_truck_assignments
DROP COLUMN IF EXISTS tare_weight_tons,
DROP COLUMN IF EXISTS net_weight_tons
`);
}
}

View File

@@ -0,0 +1,77 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* `freight.companies.region` was free text until region became a closed set
* (see ETHIOPIAN_REGIONS in @edr/types). This normalizes the rows written under
* the old rules so they satisfy the new dropdown.
*
* Two classes of bad data exist, handled differently:
*
* - Unambiguous spelling/case drift ("Addis ababa", "oromoia") — rewritten to
* the canonical spelling.
* - Values that are not regions at all ("Arba Minch", a city), and rows whose
* region contradicts their own zone/woreda — set to NULL. These are NOT
* guessed at: inferring "Gurage/Meskan" means Central Ethiopia would silently
* overwrite what the customer actually submitted. NULL surfaces the gap and
* the required dropdown forces a deliberate pick on next edit.
*/
export class NormalizeCompanyRegions2400000000000 implements MigrationInterface {
name = 'NormalizeCompanyRegions2400000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Canonical spellings — case/whitespace insensitive, safe to re-run.
await queryRunner.query(`
UPDATE freight.companies
SET region = v.canonical
FROM (VALUES
('addis ababa', 'Addis Ababa'),
('addis abeba', 'Addis Ababa'),
('addisababa', 'Addis Ababa'),
('oromia', 'Oromia'),
('oromoia', 'Oromia'),
('oromiya', 'Oromia'),
('amhara', 'Amhara'),
('somali', 'Somali'),
('afar', 'Afar'),
('tigray', 'Tigray'),
('tigrai', 'Tigray'),
('sidama', 'Sidama'),
('harari', 'Harari'),
('gambela', 'Gambela'),
('gambella', 'Gambela'),
('dire dawa', 'Dire Dawa'),
('benishangul-gumuz', 'Benishangul-Gumuz'),
('benishangul gumuz', 'Benishangul-Gumuz'),
('central ethiopia', 'Central Ethiopia'),
('south ethiopia', 'South Ethiopia')
) AS v(variant, canonical)
WHERE freight.companies.region IS NOT NULL
AND lower(regexp_replace(btrim(freight.companies.region), '\\s+', ' ', 'g')) = v.variant
AND freight.companies.region <> v.canonical
`);
// Anything still outside the canonical set is unresolvable — null it.
await queryRunner.query(`
UPDATE freight.companies
SET region = NULL
WHERE region IS NOT NULL
AND region <> ''
AND region NOT IN (
'Addis Ababa','Afar','Amhara','Benishangul-Gumuz','Central Ethiopia',
'Dire Dawa','Gambela','Harari','Oromia','Sidama','Somali',
'South Ethiopia','South West Ethiopia Peoples''','Tigray'
)
`);
// Normalize empty string to NULL so "unset" has one representation.
await queryRunner.query(`
UPDATE freight.companies SET region = NULL WHERE region = ''
`);
}
public async down(): Promise<void> {
// Irreversible by design: the original free-text values are not retained
// anywhere, so there is nothing to restore. Rolling back the code is safe —
// the column is still a nullable varchar(100) and accepts free text again.
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bookings no longer run an approval chain — accepting an intake approves the
* booking outright and generates its contract. The approval chain is now a
* contract-only concern, so `freight.approval_rules` is read by contracts alone.
*
* Also widens the role columns: chain steps now reference IAM position-type
* keys (`iam.position_types.key`), and real keys run past the old varchar(30)
* (e.g. '-marketing-manager-/-general-manager' is 38 chars), which would fail
* on insert.
*/
export class DropBookingApprovalWidenRoles2410000000000
implements MigrationInterface
{
name = 'DropBookingApprovalWidenRoles2410000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.booking_approval_step;`,
);
for (const [table, column] of [
['approval_rules', 'required_role'],
['approval_rules', 'blocks_role'],
['contract_approval_steps', 'required_role'],
['contract_approval_steps', 'blocks_role'],
] as const) {
await queryRunner.query(
`ALTER TABLE freight.${table} ALTER COLUMN ${column} TYPE varchar(64);`,
);
}
}
/**
* No-op: the booking approval chain is retired, so re-creating the table
* would leave dead schema behind. Narrowing the role columns again would
* truncate any position-type key already stored.
*/
public async down(): Promise<void> {
// intentionally empty
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Audit trail for contract document edits. The document stays editable through
* the whole approval chain (each approver may edit on their turn), so the
* contract itself only ever holds the current snapshot — this table records who
* changed which article, and when.
*/
export class CreateContractDocumentRevisions2420000000000
implements MigrationInterface
{
name = 'CreateContractDocumentRevisions2420000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_document_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
contract_id uuid NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
actor_id uuid,
actor_role varchar(64),
step_id uuid,
summary varchar(255),
changes jsonb NOT NULL DEFAULT '[]'::jsonb
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_contract_document_revisions_contract
ON freight.contract_document_revisions (contract_id, created_at DESC);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS freight.contract_document_revisions;`,
);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-document review state, so a backoffice reviewer can request a correction
* on one specific onboarding document instead of rejecting the whole role.
*
* Until now `freight.files` carried no status at all: the `pending_add` /
* `pending_remove` badges the portal shows are derived by diffing live rows
* against an open company change request, which says nothing about whether a
* reviewer is happy with a given document. `review_status` is that missing
* verdict — NULL means never reviewed, which is the state every existing row
* correctly starts in, so no backfill is needed.
*
* The partial index serves the approval gate, which asks "does this company (or
* profile) still have any document with an open change request?" on every
* role-status write.
*/
export class AddFileReviewStatus2430000000000 implements MigrationInterface {
name = 'AddFileReviewStatus2430000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.files
ADD COLUMN IF NOT EXISTS review_status varchar(32) NULL,
ADD COLUMN IF NOT EXISTS review_note text NULL,
ADD COLUMN IF NOT EXISTS reviewed_by uuid NULL,
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_files_open_change_request"
ON freight.files (resource, resource_id)
WHERE review_status = 'change_requested' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."IDX_files_open_change_request"`,
);
await queryRunner.query(`
ALTER TABLE freight.files
DROP COLUMN IF EXISTS review_status,
DROP COLUMN IF EXISTS review_note,
DROP COLUMN IF EXISTS reviewed_by,
DROP COLUMN IF EXISTS reviewed_at
`);
}
}

View File

@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Locomotive names must be unique so staff can identify a unit by name alone
* (the card view leads with `name`, falling back to `code`). Uniqueness is:
*
* - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name;
* - scoped to live rows — a decommissioned (soft-deleted) locomotive must not
* hold its name hostage, matching how the fleet reuses yard codes;
* - skipped for blank names — `name` stays optional, and NULL/'' rows are
* excluded rather than colliding with each other.
*
* A partial expression index gives all three; a plain UNIQUE column cannot.
*/
export class UniqueLocomotiveName2430000000000 implements MigrationInterface {
name = 'UniqueLocomotiveName2430000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every
// copy after the oldest (…-2, …-3) so the index can build; the oldest row
// keeps the original name. Deterministic on created_at, then id.
await queryRunner.query(`
WITH ranked AS (
SELECT
id,
name,
row_number() OVER (
PARTITION BY lower(btrim(name))
ORDER BY created_at, id
) AS rn
FROM "freight"."locomotives"
WHERE deleted_at IS NULL
AND name IS NOT NULL
AND btrim(name) <> ''
)
UPDATE "freight"."locomotives" AS l
SET name = btrim(ranked.name) || '-' || ranked.rn
FROM ranked
WHERE l.id = ranked.id
AND ranked.rn > 1
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active"
ON "freight"."locomotives" (lower(btrim("name")))
WHERE "deleted_at" IS NULL
AND "name" IS NOT NULL
AND btrim("name") <> ''
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`,
);
// The de-duplicating renames are not reversed: the original names are no
// longer recoverable, and restoring them would re-introduce the conflict.
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck EDR last-mile handovers. `truck_assignment_id` FKs
* customer_truck_assignments (self-haul only), so EDR trucks need their own
* link to the last-mile vehicle assignment that hauled the goods. Generated
* when the EDR truck exits the warehouse (with its exit paper) and signed by
* the customer in the portal — one per truck, or booking-level (both ids null)
* when the truck cannot be resolved.
*/
export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface {
name = 'AddHandoverEdrAssignment2440000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.booking_handovers
ADD COLUMN IF NOT EXISTS edr_assignment_id uuid
REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck"
ON freight.booking_handovers (booking_id, edr_assignment_id)
WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`,
);
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`,
);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drop the `active_profile_type` "active mode" column. A booking/contract now
* resolves its company_profile from the trade direction at creation time (with
* a forwarder passing an explicit companyProfileId), so no per-user active mode
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
*/
export class DropActiveProfileTypeFromExternalProfiles2450000000000
implements MigrationInterface
{
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS active_profile_type;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
`);
// Rebuild the mode the same way the original column was backfilled:
// importer first, then exporter, then whichever profile the company has.
await queryRunner.query(`
UPDATE freight.external_profiles ep
SET active_profile_type = cp.type
FROM (
SELECT DISTINCT ON (company_id) company_id, type
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE ep.company_id = cp.company_id
AND ep.active_profile_type IS NULL;
`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
name = "AddCacBankPaymentMethod2460000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
// The entity + frontend already list 'cac-bank' as a valid method, but the
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// PostgreSQL does not support removing enum values directly.
// To roll back, recreate the type without the added value and update the column.
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the
* vehicle link is optional and only for acquisitions that ARE a fleet vehicle.
*/
export class AddAcquisitionItemName2470000000000 implements MigrationInterface {
name = 'AddAcquisitionItemName2470000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
ADD COLUMN IF NOT EXISTS item_name varchar(200)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.asset_acquisitions
DROP COLUMN IF EXISTS item_name
`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Dedup stamp for the km/date-due maintenance alert — without it the daily
* cron would re-notify every day a schedule stays due.
*/
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
name = 'AddMaintenanceDueNotifiedAt2480000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* KM-based maintenance scheduling: per-vehicle service intervals (by km
* and/or days) driving the maintenance due engine. Raw schema-qualified SQL —
* the builder API resolved bare table names against the default schema and
* failed on boot ("Table maintenance_intervals does not exist").
*/
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
name = 'AddMaintenanceIntervals2800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.maintenance_intervals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE,
maintenance_type varchar NOT NULL,
interval_km numeric(14,2),
interval_days integer,
description text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`);
}
}

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Persist the signer's saved-signature image on the handover record, so the
* signed handover document can render the actual signature (not just the
* typed name) — parity with the booking-contract signing flow.
*/
export class AddSignatureToHandover2800000000001 implements MigrationInterface {
name = 'AddSignatureToHandover2800000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
);
}
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Named service items for KM-based maintenance ("oil change", "tires", …).
* The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval
* per type per vehicle, so oil and tire intervals could not coexist. Interval
* identity becomes (vehicle, maintenance_type, service_item); schedules carry
* the item so completion re-finds the right interval for auto-scheduling.
*/
export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface {
name = 'AddMaintenanceServiceItem2810000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`,
);
// Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the
// item-less legacy rows into one slot; soft-deleted rows are ignored.
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item"
ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, ''))
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type"
ON freight.maintenance_intervals (vehicle_id, maintenance_type);
`);
await queryRunner.query(
`ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`,
);
await queryRunner.query(
`ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`,
);
}
}

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the customs clearance service fee to a direction + route.
*
* The fee was a single global flat rate; the business sells it per lane —
* "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now
* carry trade_direction + the yard pair, and contract pricing matches on them
* strictly (no route-less fallback).
*
* Existing route-less clearance rates cannot be backfilled (no way to know
* which lane each was meant for) — retired exactly like the base-freight
* retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for
* snapshot history.
*/
export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface {
name = 'CustomsClearanceRouteScope2820000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'CUSTOMS_CLEARANCE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired (their lanes were never recorded); down only
// restores the pre-customs constraint shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the empty-container return surcharge to a direction + route +
* container type, like base freight (import-only for now — the box only goes
* back to the port on imports).
*
* Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired
* (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance
* were, kept readable for snapshot history. Route-scoped replacements must be
* re-entered; a booking that asks for return with no matching rate hard-blocks.
*/
export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface {
name = 'ReturnSurchargeRouteScope2830000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.rates
SET status = 'SUPERSEDED',
deleted_at = now(),
updated_at = now()
WHERE deleted_at IS NULL
AND rate_type = 'RETURN_SURCHARGE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired; down only restores the customs-era shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -0,0 +1,62 @@
import { Body, Controller, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { AccountService } from "./account.service";
import {
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
/**
* The caller's own account record. Everything here is scoped to the JWT's user
* id — there is no `:id` parameter to tamper with, so these routes need no
* permission key beyond being authenticated.
*/
@ApiTags("auth")
@Controller("me")
@ApiBearerAuth()
@UseGuards(JwtGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Post("contact/otp")
@ApiOperation({
summary: "Send a verification code to a new email/phone before changing it",
description:
"The code goes to the NEW value supplied here, proving the caller controls " +
"it. Returns the target masked — an unverified caller never gets it back in full.",
})
sendContactOtp(
@CurrentUser() user: TCurrentUser,
@Body() dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
return this.accountService.sendContactOtp(user.id, dto);
}
@Patch("contact")
@ApiOperation({
summary: "Change the account's email or phone, gated by a verification code",
description:
"Verifies the code and writes the new value in one call, so the API never " +
"has to take a client's word that verification happened.",
})
updateContact(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
return this.accountService.updateContact(user.id, dto);
}
@Patch("name")
@ApiOperation({ summary: "Change the account's display name" })
updateName(
@CurrentUser() user: TCurrentUser,
@Body() dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
return this.accountService.updateName(user.id, dto);
}
}

View File

@@ -0,0 +1,226 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, EntityManager, Repository } from "typeorm";
import { isValidPhoneNumber } from "libphonenumber-js";
import { EUserVerifiedBy } from "@tria-plc/api-common/utils/enums/user.enum";
import type { TCurrentTokenUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { Employee } from "@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { OtpService, OtpTarget } from "../otp/otp.service";
import {
ContactChannel,
SendContactOtpDto,
UpdateAccountNameDto,
UpdateContactDto,
} from "./dto/account.dto";
import { maskOtpTarget } from "./mask-target.util";
/** How long a contact-change code stays valid before it must be re-requested. */
const CONTACT_OTP_TTL_MS = 10 * 60 * 1000;
/** Postgres unique-violation SQLSTATE. */
const PG_UNIQUE_VIOLATION = "23505";
/**
* Self-serve management of the caller's own IAM user record.
*
* IAM ships `PATCH /api/auth/update-profile`, but it takes email + username +
* phone + name all at once (every field `@IsNotEmpty`) and performs no
* verification — it will move an account's phone to any number the caller
* types. These routes exist so a contact change is *proven*: the code goes to
* the NEW address and the write only lands once it comes back.
*/
@Injectable()
export class AccountService {
private readonly logger = new Logger(AccountService.name);
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly otpService: OtpService,
) {}
/**
* Send a code to the address the caller wants to move TO. Sending to the new
* value (rather than the one on file) is the whole point — it proves control
* of the destination before anything is written.
*/
async sendContactOtp(
userId: string,
dto: SendContactOtpDto,
): Promise<{ sentTo: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
const target = this.targetFor(dto.channel, value);
await this.otpService.sendOtp(target);
return { sentTo: maskOtpTarget(target) };
}
/**
* Verify the code, then write the new contact value. The verify and the write
* are one call: the API never has to trust that a client "already verified"
* — unlike the signup flow, where the OTP is client-orchestrated and
* `POST /api/otp/verify` is a separate public route the client may simply skip.
*/
async updateContact(
userId: string,
dto: UpdateContactDto,
): Promise<{ success: true; value: string }> {
const value = this.normalize(dto.channel, dto.value);
await this.assertNotTaken(dto.channel, value, userId);
await this.otpService.verifyOtpForAction(
this.targetFor(dto.channel, value),
dto.otp,
CONTACT_OTP_TTL_MS,
);
const isEmail = dto.channel === ContactChannel.Email;
const userPatch = isEmail
? { email: value }
: {
phoneNumber: value,
// The number just passed an OTP, which is exactly what IAM's own
// phone-verification flag means. Set it here so the freight app stops
// needing its own parallel "verified phone" bookkeeping.
isPhoneNumberVerified: true,
verifiedBy: EUserVerifiedBy.PHONE_NUMBER,
};
const sessionPatch: Partial<TCurrentTokenUser> = isEmail
? { email: value }
: { phoneNumber: value, isPhoneNumberVerified: true };
try {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, userPatch);
await this.refreshSessions(manager, userId, sessionPatch);
});
} catch (error) {
throw this.asConflict(error, dto.channel);
}
this.logger.log(`Account ${dto.channel} updated for user ${userId}`);
return { success: true, value };
}
/** Rename the account. No OTP — a name change proves nothing and grants nothing. */
async updateName(
userId: string,
dto: UpdateAccountNameDto,
): Promise<{ success: true }> {
const en = dto.name.en?.trim();
const name = { am: dto.name.am.trim(), ...(en ? { en } : {}) };
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(User).update({ id: userId }, { name });
// IAM mirrors the name onto the employee row. Portal customers are
// `individual` users with no employee row at all, so this is a no-op for
// them — hence an unconditional update() rather than a lookup-then-write.
await manager.getRepository(Employee).update({ userId }, { name });
await this.refreshSessions(manager, userId, { name });
});
return { success: true };
}
/**
* `GET /api/auth/me` serves `session.userInfo` — a snapshot IAM writes only
* when a session is created at login. Without patching it here, a saved change
* stays invisible to /me (and to anything reading the token's claims) until the
* user logs out and back in, which reads as "my edit didn't save".
*/
private async refreshSessions(
manager: EntityManager,
userId: string,
patch: Partial<TCurrentTokenUser>,
): Promise<void> {
const repo = manager.getRepository(Session);
const sessions = await repo.find({ where: { userId } });
await Promise.all(
sessions.map((session) =>
repo.update(
{ id: session.id },
{ userInfo: { ...session.userInfo, ...patch } },
),
),
);
}
/** Canonicalise for the channel and reject anything malformed up front. */
private normalize(channel: ContactChannel, value: string): string {
const raw = value.trim();
if (channel === ContactChannel.Email) {
const email = raw.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw new BadRequestException("A valid email address is required");
}
return email;
}
if (!isValidPhoneNumber(raw)) {
throw new BadRequestException(
"A valid international phone number is required (E.164, e.g. +251911223344)",
);
}
// Store the same canonical form the OTP is keyed by, so the code sent here
// is findable on verify regardless of how the number was typed.
return normalizeE164(raw) as string;
}
private targetFor(channel: ContactChannel, value: string): OtpTarget {
return channel === ContactChannel.Email ? { email: value } : { phone: value };
}
/**
* `iam.users.email` and `.phone_number` are each independently UNIQUE, so a
* collision would otherwise surface as a raw 500 at write time. This is a
* courtesy check, not the guard — it races, so {@link asConflict} still has to
* catch the violation.
*/
private async assertNotTaken(
channel: ContactChannel,
value: string,
userId: string,
): Promise<void> {
const existing = await this.userRepository.findOne({
where:
channel === ContactChannel.Email
? { email: value }
: { phoneNumber: value },
select: { id: true },
});
if (existing && existing.id !== userId) {
throw this.takenError(channel);
}
}
private asConflict(error: unknown, channel: ContactChannel): Error {
const code = (error as { code?: string } | null)?.code;
if (code === PG_UNIQUE_VIOLATION) return this.takenError(channel);
return error as Error;
}
private takenError(channel: ContactChannel): ConflictException {
return new ConflictException(
channel === ContactChannel.Email
? "That email address is already registered to another account"
: "That phone number is already registered to another account",
);
}
}

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
@@ -11,11 +12,14 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingStaff } from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto";
import { CustomerResetService } from "./customer-reset.service";
import {
CustomerResetService,
CustomerResetTarget,
} from "./customer-reset.service";
/**
* Staff-triggered password reset. The customer receives the code and sets their
* own password — staff never see or handle a credential.
* Staff-triggered password reset. The customer receives a single-use link and
* sets their own password — staff never see or handle a credential.
*/
@ApiTags("backoffice")
@Controller("backoffice/customers")
@@ -23,26 +27,45 @@ import { CustomerResetService } from "./customer-reset.service";
export class CustomerResetController {
constructor(private readonly customerResetService: CustomerResetService) {}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "The primary contact's IAM account a reset link would be sent to",
})
async resetTarget(
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<CustomerResetTarget> {
const target = await this.customerResetService.getResetTarget(companyId);
if (!target) {
throw new NotFoundException(
"This customer has no active primary-contact account to reset",
);
}
return target;
}
@Post(":companyId/reset-password")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)
@ApiOperation({
summary: "Send a password-reset code to a customer's primary contact",
summary: "Send a password-reset link to a customer's primary contact",
})
async resetPassword(
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: BackofficeResetPasswordDto,
) {
const maskedTarget = await this.customerResetService.sendResetToCustomer(
const sent = await this.customerResetService.sendResetLinkToCustomer(
companyId,
dto.channel,
);
if (!maskedTarget) {
if (!sent) {
throw new NotFoundException(
`No active primary contact with ${dto.channel === "email" ? "an email address" : "a phone number"} for this customer`,
);
}
return { channel: dto.channel, maskedTarget };
return sent;
}
}

View File

@@ -1,10 +1,38 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ExternalProfile } from "../companies/entities/external-profile.entity";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { ForgotPasswordService } from "./forgot-password.service";
import {
ForgotPasswordService,
RESET_LINK_TTL_MS,
} from "./forgot-password.service";
import { maskOtpTarget } from "./mask-target.util";
import { isDomesticPhone } from "../otp/otp.service";
/** The account a staff-triggered reset would land on. */
export interface CustomerResetTarget {
userId: string;
name: string;
email: string | null;
phone: string | null;
/**
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
* there is no phone. The backoffice uses this to disable the SMS channel for
* foreign numbers instead of sending a link that will never arrive.
*/
phoneIsDomestic: boolean | null;
}
export interface SentResetLink {
channel: ResetChannel;
maskedTarget: string;
expiresAt: string;
}
@Injectable()
export class CustomerResetService {
@@ -14,19 +42,124 @@ export class CustomerResetService {
@InjectRepository(ExternalProfile)
private readonly externalProfileRepository: Repository<ExternalProfile>,
private readonly forgotPasswordService: ForgotPasswordService,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
private readonly config: ConfigService,
) {}
/**
* Send a reset code to the company's primary contact. Returns the masked
* destination, or null when there is no eligible account for that channel.
* The IAM account a reset would actually reach. The backoffice shows these
* values rather than `company.email` / `company.phone`: the company row holds
* business contact detail, while the link is delivered to the primary
* contact's own login credentials — the two drift apart routinely, and showing
* the wrong one has staff telling customers to check an inbox nothing was sent
* to.
*/
async getResetTarget(companyId: string): Promise<CustomerResetTarget | null> {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { profile, user, userId } = resolved;
return {
userId,
name: `${profile.firstName} ${profile.lastName}`.trim(),
email: user.email ?? null,
phone: user.phoneNumber ?? null,
phoneIsDomestic: user.phoneNumber
? isDomesticPhone(user.phoneNumber)
: null,
};
}
/**
* Mint a password-reset link and send it to the company's primary contact.
* Returns the masked destination, or null when there is no eligible account
* for that channel.
*
* Unlike the public flow this reports failure honestly — the caller is an
* authenticated staff member, so there is nothing to enumerate.
*/
async sendResetToCustomer(
async sendResetLinkToCustomer(
companyId: string,
channel: ResetChannel,
): Promise<string | null> {
): Promise<SentResetLink | null> {
const resolved = await this.resolvePrimaryContactUser(companyId);
if (!resolved) return null;
const { user, userId } = resolved;
const target = this.forgotPasswordService.targetFor(user, channel);
if (!target) return null;
// A foreign number is unreachable by the domestic-only SMS gateway — treat
// it like a missing phone rather than reporting "link sent" for a message
// that will never arrive. The backoffice disables the channel up front via
// `phoneIsDomestic`; this guards direct API calls.
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
this.logger.warn(
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
);
return null;
}
// Mint first, send second: a failed send leaves an unused ticket that simply
// expires, whereas sending a link before the ticket exists would hand the
// customer a URL that is dead on arrival.
const ticket = await this.forgotPasswordService.mintResetTicket(
userId,
RESET_LINK_TTL_MS,
);
const link = this.buildResetLink(ticket.userId, ticket.verificationCode);
const expiresAt = new Date(Date.now() + RESET_LINK_TTL_MS);
const { queued } = target.email
? await this.emailClient.sendEmail({
to: target.email,
subject: "Reset your EDR Freight password",
text:
"A password reset was started for your EDR Freight account.\n\n" +
`Open this link to choose a new password:\n${link}\n\n` +
"The link expires in 24 hours and can only be used once. If you did " +
"not expect this, ignore this message — your password stays unchanged.",
})
: await this.smsClient.sendSms({
to: target.phone as string,
message: `Reset your EDR Freight password: ${link} (expires in 24 hours, single use)`,
});
this.logger.log(
`Staff-triggered ${channel} reset link sent to user ${userId} (company ${companyId}) queued=${queued}`,
);
if (!queued) {
// The ticket is committed and the backoffice is about to say "link sent",
// but nothing left this process — with RABBITMQ_ENABLED=false both clients
// are no-ops. Without this line the only symptom is a customer who never
// receives anything, indistinguishable from carrier loss.
this.logger.error(
`reset-link.dispatch.dropped channel=${channel} user=${userId} rabbitmqEnabled=${
process.env.RABBITMQ_ENABLED ?? "unset"
} — transport reported no hand-off; no link will arrive`,
);
// SECURITY: logs a live password-reset credential in cleartext. Same
// deliberate tradeoff the OTP service makes — this is the only way to
// complete a reset on an environment with no broker. Only reached when
// delivery already failed.
this.logger.warn(`Undelivered reset link for user ${userId}: ${link}`);
}
return {
channel,
maskedTarget: maskOtpTarget(target),
expiresAt: expiresAt.toISOString(),
};
}
/**
* The company's primary contact, gated on the same active-account rule the
* public flow uses — so a suspended customer cannot be reactivated by a
* staff-triggered reset (IAM's `set-password` flips `isActive` back on).
*/
private async resolvePrimaryContactUser(companyId: string) {
const profile = await this.externalProfileRepository.findOne({
where: { companyId, isPrimaryContact: true },
});
@@ -36,24 +169,28 @@ export class CustomerResetService {
return null;
}
// Resolve through the same active-account gate the public flow uses, so a
// suspended customer cannot be reactivated by a staff-triggered reset.
const user = await this.forgotPasswordService.resolveActiveUserById(
profile.userId,
);
if (!user) {
if (!user?.id) {
this.logger.warn(
`Primary contact ${profile.userId} of company ${companyId} is not an active account`,
);
return null;
}
const target = await this.forgotPasswordService.requestReset(user, channel);
if (!target) return null;
return { profile, user, userId: user.id };
}
this.logger.log(
`Staff-triggered ${channel} reset sent to user ${user.id} (company ${companyId})`,
);
return this.forgotPasswordService.maskTarget(target);
/**
* The portal route that trades the token for a set-password form. Params are
* URL-encoded because the token is base64url — safe as-is, but the encoding
* keeps this correct if the token format ever changes.
*/
private buildResetLink(userId: string, token: string): string {
const base = this.config.get<string>("app.portalBaseUrl");
return `${base}/reset-password?uid=${encodeURIComponent(
userId,
)}&token=${encodeURIComponent(token)}`;
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
/** The contact channel being changed on the caller's own account. */
export enum ContactChannel {
Email = "email",
Phone = "phone",
}
export class SendContactOtpDto {
@ApiProperty({ enum: ContactChannel })
@IsEnum(ContactChannel)
channel!: ContactChannel;
@ApiProperty({
description:
"The NEW email or phone to verify. The code is sent here, not to the " +
"address currently on the account — that is what proves the caller " +
"controls the number/inbox they are moving to.",
example: "+251911223344",
})
@IsString()
@IsNotEmpty()
value!: string;
}
export class UpdateContactDto extends SendContactOtpDto {
@ApiProperty({ description: "The 6-digit code sent to the new value" })
@IsString()
@IsNotEmpty()
otp!: string;
}
export class AccountNameDto {
@ApiProperty({ description: "Amharic name", example: "አበበ በቀለ" })
@IsString()
@IsNotEmpty()
am!: string;
@ApiPropertyOptional({ description: "English name", example: "Abebe Bekele" })
@IsOptional()
@IsString()
en?: string;
}
export class UpdateAccountNameDto {
@ApiProperty({ type: AccountNameDto })
@IsObject()
@ValidateNested()
@Type(() => AccountNameDto)
name!: AccountNameDto;
}

View File

@@ -1,7 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
/** The channel the reset code is delivered over. */
/**
* The channel a reset LINK is delivered over. The OTP flow no longer picks one —
* it sends to every contact on the account — but the staff-triggered link flow
* still delivers over exactly one transport.
*/
export enum ResetChannel {
Email = "email",
Phone = "phone",
@@ -16,13 +20,27 @@ export class ForgotPasswordRequestDto {
@IsNotEmpty()
identifier!: string;
@ApiProperty({ enum: ResetChannel })
/**
* Accepted and ignored. The code now goes to the account's email AND phone,
* so there is nothing to choose — kept optional so clients still sending it
* (older portal/backoffice builds) are not rejected outright.
* @deprecated
*/
@ApiPropertyOptional({
enum: ResetChannel,
deprecated: true,
description: "Ignored — the code is sent to every contact on the account.",
})
@IsOptional()
@IsEnum(ResetChannel)
channel!: ResetChannel;
channel?: ResetChannel;
}
export class ForgotPasswordVerifyDto extends ForgotPasswordRequestDto {
@ApiProperty({ description: "The 6-digit code sent to the chosen channel" })
@ApiProperty({
description:
"The 6-digit code sent to the account's email and phone. Either delivery carries the same code.",
})
@IsString()
@IsNotEmpty()
otp!: string;
@@ -33,3 +51,19 @@ export class BackofficeResetPasswordDto {
@IsEnum(ResetChannel)
channel!: ResetChannel;
}
/**
* The two halves of a reset link's query string. Together they stand in for the
* identifier + OTP pair of the typed flow: the token proves possession of the
* inbox/handset the link was delivered to.
*/
export class ResolveResetLinkDto {
@ApiProperty({ description: "IAM user id from the reset link's `uid` param" })
@IsUUID()
userId!: string;
@ApiProperty({ description: "Opaque token from the reset link's `token` param" })
@IsString()
@IsNotEmpty()
token!: string;
}

View File

@@ -5,8 +5,13 @@ import { Public } from "@edr/api-common";
import {
ForgotPasswordRequestDto,
ForgotPasswordVerifyDto,
ResolveResetLinkDto,
} from "./dto/forgot-password.dto";
import { ForgotPasswordService, ResetTicket } from "./forgot-password.service";
import {
ForgotPasswordService,
ResetLinkAccount,
ResetTicket,
} from "./forgot-password.service";
/**
* Freight-owned reset flow. IAM ships a `forgot-password` route, but it only
@@ -24,17 +29,19 @@ export class ForgotPasswordController {
@Post("forgot-password/request")
@ApiOperation({
summary: "Send a password-reset code over email or SMS",
summary: "Send a password-reset code to the account's email AND phone",
description:
"Always reports success. An unknown, inactive, or channel-less account is " +
"indistinguishable from a real one, so this cannot be used to enumerate accounts.",
"One code, delivered over every contact the account has; either delivery " +
"verifies it. Always reports success — an unknown, inactive, or contactless " +
"account is indistinguishable from a real one, so this cannot be used to " +
"enumerate accounts.",
})
async request(@Body() dto: ForgotPasswordRequestDto): Promise<{ success: true }> {
const user = await this.forgotPasswordService.resolveActiveUser(dto.identifier);
if (user) {
try {
await this.forgotPasswordService.requestReset(user, dto.channel);
await this.forgotPasswordService.requestReset(user);
} catch (error) {
// A delivery failure must not change the response shape either — log it
// and let the caller sit on the OTP screen.
@@ -60,10 +67,18 @@ export class ForgotPasswordController {
"alongside the same identifier and the new password.",
})
verify(@Body() dto: ForgotPasswordVerifyDto): Promise<ResetTicket> {
return this.forgotPasswordService.verifyAndMintTicket(
dto.identifier,
dto.channel,
dto.otp,
);
return this.forgotPasswordService.verifyAndMintTicket(dto.identifier, dto.otp);
}
@Post("forgot-password/resolve-link")
@ApiOperation({
summary: "Validate a staff-issued reset link and return its set-password ticket",
description:
"Takes the link's uid/token pair. The returned { userId, identifier, verificationCode } " +
"is the body for PATCH /api/auth/set-password, so the customer never types an identifier. " +
"A bad or expired link is rejected here rather than after the password is typed.",
})
resolveLink(@Body() dto: ResolveResetLinkDto): Promise<ResetLinkAccount> {
return this.forgotPasswordService.resolveResetLink(dto.userId, dto.token);
}
}

View File

@@ -4,13 +4,14 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { hashPassword } from "@tria-plc/api-common/utils/argon";
import { hashPassword, verifyPassword } from "@tria-plc/api-common/utils/argon";
import { EOtpType } from "@tria-plc/iamapi-common/enums/otp.enum";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { UserVerification } from "@tria-plc/iamapi-common/entities/iam/user/user-verification.entity";
import { OtpService, OtpTarget } from "../otp/otp.service";
import { ResetChannel } from "./dto/forgot-password.dto";
import { maskOtpTarget } from "./mask-target.util";
/**
* How long the reset ticket minted for `PATCH /api/auth/set-password` stays
@@ -21,11 +22,32 @@ const RESET_TICKET_TTL_MS = 10 * 60 * 1000;
/** How long the emailed/SMS'd OTP stays valid before it must be re-requested. */
const RESET_OTP_TTL_MS = 10 * 60 * 1000;
/**
* A staff-triggered reset link lives longer than a typed OTP: the customer may
* only see the SMS/email hours after the call that prompted it.
*/
export const RESET_LINK_TTL_MS = 24 * 60 * 60 * 1000;
/** IAM refuses a ticket once its row hits this many failed attempts. */
const MAX_TICKET_ATTEMPTS = 5;
export interface ResetTicket {
userId: string;
verificationCode: string;
}
/**
* What a valid reset link resolves to. `identifier` is the value IAM's
* `set-password` matches the user on (it accepts email / username / phone), so
* the portal can spend the ticket without the customer typing anything.
*/
export interface ResetLinkAccount {
userId: string;
identifier: string;
maskedIdentifier: string;
verificationCode: string;
}
@Injectable()
export class ForgotPasswordService {
private readonly logger = new Logger(ForgotPasswordService.name);
@@ -80,8 +102,12 @@ export class ForgotPasswordService {
.orderBy("u.createdAt", "DESC");
}
/** The address the code goes to, taken from the account — never from input. */
private targetFor(user: User, channel: ResetChannel): OtpTarget | null {
/**
* A single channel of the account, for flows that genuinely deliver over one
* transport (the staff-triggered reset LINK picks email or SMS). Taken from
* the account — never from input.
*/
targetFor(user: User, channel: ResetChannel): OtpTarget | null {
if (channel === ResetChannel.Email) {
return user.email ? { email: user.email } : null;
}
@@ -89,20 +115,40 @@ export class ForgotPasswordService {
}
/**
* Send a reset code to the account's own email/phone. Returns the target so
* authenticated (backoffice) callers can echo a masked version; unauthenticated
* callers must discard it.
* Every contact the account has. The reset OTP goes to all of them and any one
* verifies it — a customer whose SMS never lands can finish from their inbox
* without restarting the flow on a different channel. An account holding only
* one of the two degrades to that channel; only a contactless account is null.
*/
targetsFor(user: User): OtpTarget | null {
const target: OtpTarget = {};
if (user.email) target.email = user.email;
if (user.phoneNumber) target.phone = user.phoneNumber;
return target.email || target.phone ? target : null;
}
/**
* The value IAM's `set-password` will match this account on. It looks the user
* up by email OR username OR phoneNumber (and lowercases whatever it is
* given), so prefer email, then phone, and fall back to username last —
* a mixed-case username would not survive that lowercasing.
*/
private identifierFor(user: User): string | null {
return user.email ?? user.phoneNumber ?? user.username ?? null;
}
/**
* Send one reset code to every contact on the account — email AND phone —
* returning the target so authenticated (backoffice) callers can echo a masked
* version; unauthenticated callers must discard it.
*
* Note: `otp_verifications` keys rows by a unique phone/email, and `sendOtp`
* upserts. A reset request therefore overwrites any pending signup code for
* the same address — last code sent wins. That is the pre-existing behaviour
* between any two flows sharing this table.
* replaces every row the target overlaps. A reset request therefore overwrites
* any pending signup code for the same addresses — last code sent wins. That
* is the pre-existing behaviour between any two flows sharing this table.
*/
async requestReset(
user: User,
channel: ResetChannel,
): Promise<OtpTarget | null> {
const target = this.targetFor(user, channel);
async requestReset(user: User): Promise<OtpTarget | null> {
const target = this.targetsFor(user);
if (!target) return null;
await this.otpService.sendOtp(target);
@@ -119,11 +165,12 @@ export class ForgotPasswordService {
*/
async verifyAndMintTicket(
identifier: string,
channel: ResetChannel,
otp: string,
): Promise<ResetTicket> {
const user = await this.resolveActiveUser(identifier);
const target = user && this.targetFor(user, channel);
// Same set of contacts `requestReset` sent to, so the code resolves whichever
// of the two the customer actually received it on.
const target = user && this.targetsFor(user);
if (!user?.id || !target) {
// Same shape as a wrong code: a caller probing for accounts learns nothing
@@ -133,9 +180,18 @@ export class ForgotPasswordService {
await this.otpService.verifyOtpForAction(target, otp, RESET_OTP_TTL_MS);
return await this.mintResetTicket(user.id, RESET_TICKET_TTL_MS);
}
/**
* Mint a single-use IAM reset ticket. Shared by the OTP flow (where the code
* is the proof of possession) and the staff-triggered link flow (where the
* ticket travels in the link and delivery to the account's own inbox/handset
* is the proof).
*/
async mintResetTicket(userId: string, ttlMs: number): Promise<ResetTicket> {
const code = randomBytes(24).toString("base64url");
const verificationCode = await hashPassword(code);
const userId = user.id;
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(UserVerification);
@@ -146,7 +202,7 @@ export class ForgotPasswordService {
userId,
otpType: EOtpType.RESET_PASSWORD,
verificationCode,
expiresAt: new Date(Date.now() + RESET_TICKET_TTL_MS),
expiresAt: new Date(Date.now() + ttlMs),
isUsed: false,
attemptCount: 0,
});
@@ -156,14 +212,65 @@ export class ForgotPasswordService {
return { userId, verificationCode: code };
}
/**
* Validate a reset link and hand back everything the portal needs to spend it
* on IAM's `PATCH /api/auth/set-password`.
*
* The checks mirror IAM's own — newest row, unused, unexpired, attempts left,
* argon match — so a link that resolves here is one IAM will honour. Doing
* them up front is what lets the page say "this link has expired" before the
* customer types a password rather than after.
*
* Every rejection is the same message: a link is a bearer credential, and the
* holder of a bad one learns nothing about why it failed or whether the user
* id exists.
*/
async resolveResetLink(
userId: string,
token: string,
): Promise<ResetLinkAccount> {
const invalid = new BadRequestException(
"This password-reset link is invalid or has expired. Request a new one.",
);
const user = await this.resolveActiveUserById(userId);
const identifier = user && this.identifierFor(user);
if (!user || !identifier) throw invalid;
const verification = await this.dataSource
.getRepository(UserVerification)
.findOne({
where: { userId, otpType: EOtpType.RESET_PASSWORD },
order: { createdAt: "DESC" },
});
// `expiresAt` / `attemptCount` are optional on IAM's entity but always
// written by `mintResetTicket`. A row missing either is malformed, so treat
// it as expired rather than letting it through unchecked.
if (
!verification ||
verification.isUsed ||
!verification.expiresAt ||
verification.expiresAt < new Date() ||
(verification.attemptCount ?? 0) >= MAX_TICKET_ATTEMPTS ||
!(await verifyPassword(token, verification.verificationCode))
) {
this.logger.warn(`Reset link rejected for user ${userId}`);
throw invalid;
}
return {
userId,
identifier,
maskedIdentifier: maskOtpTarget(
identifier.includes("@") ? { email: identifier } : { phone: identifier },
),
verificationCode: token,
};
}
/** `+251911234567` -> `+251•••••4567`; `ab@x.com` -> `a•@x.com`. */
maskTarget(target: OtpTarget): string {
if (target.email) {
const [local, domain] = target.email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
const phone = target.phone ?? "";
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
return maskOtpTarget(target);
}
}

View File

@@ -1,11 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common/entities/iam/organization-structure/employee.entity';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { UserVerification } from '@tria-plc/iamapi-common/entities/iam/user/user-verification.entity';
import { ExternalProfile } from '../companies/entities/external-profile.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { OtpModule } from '../otp/otp.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { CustomerResetController } from './customer-reset.controller';
@@ -17,17 +22,27 @@ import { FreightMeService } from './freight-me.service';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserVerification, ExternalProfile]),
TypeOrmModule.forFeature([
User,
UserVerification,
ExternalProfile,
Session,
Employee,
]),
OtpModule,
// Reset links go out over email/SMS directly, not through the OTP service.
NotificationsModule,
],
controllers: [
FreightMeController,
AccountController,
CheckAvailabilityController,
ForgotPasswordController,
CustomerResetController,
],
providers: [
FreightMeService,
AccountService,
CheckAvailabilityService,
ForgotPasswordService,
CustomerResetService,

View File

@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { DataSource } from 'typeorm';
import {
collectPermissionKeys,
@@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
@Injectable()
export class FreightMeService {
getEnrichedProfile(user: TCurrentUser) {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
/**
* The JWT session snapshot has no position TYPE, but the backoffice needs it
* (GL sub-positions are identified by type key). Resolved live from IAM.
*/
private async lookupPositionType(
positionId: string | undefined,
): Promise<{ key: string; name: unknown } | null> {
if (!positionId) return null;
try {
const rows: { key: string; name: unknown }[] = await this.dataSource.query(
`SELECT pt.key, pt.name
FROM iam.positions p
JOIN iam.position_types pt ON pt.id = p.position_type_id
WHERE p.id = $1`,
[positionId],
);
return rows[0] ?? null;
} catch {
return null; // iam schema unreachable — degrade to the old payload shape
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
);
const employee = user.employee
? [
{
@@ -27,6 +57,7 @@ export class FreightMeService {
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
positionType,
},
]
: [],

View File

@@ -0,0 +1,27 @@
import { OtpTarget } from "../otp/otp.service";
function maskEmail(email: string): string {
const [local, domain] = email.split("@");
const head = local.slice(0, 1);
return `${head}${"•".repeat(Math.max(local.length - 1, 1))}@${domain}`;
}
function maskPhone(phone: string): string {
return `${phone.slice(0, 4)}${"•".repeat(Math.max(phone.length - 8, 1))}${phone.slice(-4)}`;
}
/**
* Mask an OTP target for echoing back to the caller: `+251911234567` ->
* `+251•••••4567`; `ab@x.com` -> `a•@x.com`. Never return an unmasked target to
* a caller who has not yet proven possession of the channel.
*
* A dual-channel target masks both and joins them, so the UI can say exactly
* where the code went ("a•@x.com and +251•••••4567") — a user who only checks
* one of the two otherwise assumes the other never received anything.
*/
export function maskOtpTarget(target: OtpTarget): string {
const parts: string[] = [];
if (target.email) parts.push(maskEmail(target.email));
if (target.phone) parts.push(maskPhone(target.phone));
return parts.join(" and ");
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
import { Type } from "class-transformer";
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
class CreateOrganizationUserNameDto {
@ApiProperty()
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
phoneNumber?: string;
@ApiProperty({ type: CreateOrganizationUserNameDto })
@IsObject()
@ValidateNested()
@Type(() => CreateOrganizationUserNameDto)
name!: CreateOrganizationUserNameDto;
@ApiProperty({ required: false, default: false })

View File

@@ -1016,7 +1016,7 @@ export class BillingService {
// in the domain via `${source}.invoice.paid`. Neither billing nor the payment
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace("-", "_"),
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
@@ -1032,10 +1032,8 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",

View File

@@ -16,6 +16,7 @@ import {
InvoiceLineInput,
} from "../billing/billing.service";
import { Invoice } from "../billing/entities/invoice.entity";
import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service";
import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
@@ -120,17 +121,27 @@ export class BookingInvoiceService {
}
/**
* Expire the booking's currently-open prepaid invoice when the booking is
* Expire the booking's currently-open invoices (freight PREPAID and the
* per-shipment clearance fee) when the booking is
* cancelled or rejected — the counterpart to the pay-window-expiry path
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
* booking from leaving a payable invoice open. No-op when the booking has no
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
* caller `manager` to enlist in its transaction.
*/
expireOpenInvoices(
async expireOpenInvoices(
bookingId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
// The per-shipment clearance fee (GENERAL contracts) bills this same booking
// id under its own source/type — retire it alongside the freight invoice, or
// a cancelled shipment keeps a payable clearance invoice open.
await this.billing.expirePayable(
Freight.InvoiceSource.Clearance,
bookingId,
CLEARANCE_BOOKING_INVOICE_TYPE,
manager,
);
return this.billing.expirePayable(
Freight.InvoiceSource.Booking,
bookingId,

View File

@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
NotificationAudience,
NotificationType,
@@ -8,6 +10,7 @@ import {
import { Booking } from './entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { resolveCompanyNotifyPhone } from '../notifications/resolve-company-phone.util';
/**
* Customer + staff notifications for the booking lifecycle: review, clearance
@@ -27,6 +30,8 @@ export class BookingLifecycleNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
private ref(b: Booking): string {
@@ -40,7 +45,9 @@ export class BookingLifecycleNotifierService {
logLabel: string,
): Promise<void> {
this.logger.log(`${logLabel}${this.ref(b)}`);
const phone = b.company?.contactPersonPhone ?? b.company?.phone ?? null;
const phone = b.companyId
? await resolveCompanyNotifyPhone(this.dataSource, b.companyId)
: null;
const email = b.company?.email ?? b.company?.generalManagerEmail ?? null;
if (phone) {
@@ -150,13 +157,24 @@ export class BookingLifecycleNotifierService {
});
}
/** Clearance finalized → customer can proceed to request operation. */
/** Document approval finalized → customer can proceed to request operation. */
clearanceReady(b: Booking): void {
const msg =
`Clearance for booking ${b.reference} is complete. ` +
`Document approval for booking ${b.reference} is finalized. ` +
`You can now proceed to request operation from the portal.`;
void this.notifyContact(b, msg, 'CLEARANCE READY');
this.inApp(b, 'Clearance complete', msg, {
void this.notifyContact(b, msg, 'DOCUMENT APPROVAL FINALIZED');
this.inApp(b, 'Document approval finalized', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
/** Intercity documents approved → booking waits in the ride-along pool. */
intercityDocumentsApproved(b: Booking): void {
const msg =
`Documents for intercity booking ${b.reference} are approved. ` +
`Operations will assign your shipment to a passing train; payment opens once it is accepted.`;
void this.notifyContact(b, msg, 'DOCUMENTS APPROVED');
this.inApp(b, 'Documents approved', msg, {
type: NotificationType.CLEARANCE_DECISION,
});
}
@@ -246,6 +264,19 @@ export class BookingLifecycleNotifierService {
// ── Staff-facing (backoffice inbox) ────────────────────────────────────────
/**
* A booking was created under a contract. Contract drawdowns never pass
* through submit, so this is the only point at which staff learn the booking
* exists — {@link submittedToStaff} covers the direct-booking flow instead.
*/
createdToStaff(b: Booking): void {
this.inAppStaff(
b,
'New booking created',
`Booking ${this.ref(b)} was created under a contract and has entered the pipeline.`,
);
}
/** Customer submitted a booking for review. */
submittedToStaff(b: Booking): void {
this.inAppStaff(

View File

@@ -1,4 +1,3 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
@@ -9,7 +8,11 @@ export interface BookingNextStep {
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
/**
* Retained for call-site compatibility — bookings no longer run an approval
* chain, so this is always null. Approvals are a contract-only concern.
*/
nextPendingStep?: { requiredRole: string; stepOrder: number } | null,
): BookingNextStep | null {
const { status } = booking;

View File

@@ -4,6 +4,12 @@ import type { Rate } from '../rule-engine/entities/rate.entity';
const MOCK_CBE_RATE = 130;
// Base freight is configured per leg, so every rate and every booking names the
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
const MOJO = 'yard-mojo';
const DIRE = 'yard-dire-dawa';
const LEBU = 'yard-lebu';
describe('BookingPricingService — domestic corridor', () => {
const intercityBulkUsd: Rate = {
id: 'rate-intercity-bulk-usd',
@@ -13,6 +19,8 @@ describe('BookingPricingService — domestic corridor', () => {
rateUnit: 'PER_TON',
status: 'LIVE',
containerTypeId: null,
originYardId: MOJO,
destinationYardId: DIRE,
} as Rate;
const intercityContainerUsd: Rate = {
@@ -23,6 +31,8 @@ describe('BookingPricingService — domestic corridor', () => {
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: null,
originYardId: MOJO,
destinationYardId: DIRE,
} as Rate;
let service: BookingPricingService;
@@ -56,6 +66,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 120,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -81,6 +93,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -106,6 +120,8 @@ describe('BookingPricingService — domestic corridor', () => {
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 50,
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
@@ -126,4 +142,101 @@ describe('BookingPricingService — domestic corridor', () => {
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
expect(line.currency).toBe('ETB');
});
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
// nobody configured this one yet is worse than billing no base freight.
it('does not price bulk off a rate configured for a different leg', async () => {
const booking = {
id: 'b-3',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
originYardId: LEBU,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(0);
expect(result.blocked).toHaveLength(1);
});
it('does not price containers off a rate configured for a different leg', async () => {
const booking = {
id: 'b-4',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 50,
originYardId: LEBU,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ amount: number }> }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
});
expect(result.lineItems).toHaveLength(0);
});
// A mixed booking where only one container size has a configured rate must
// hard-block, not silently carry the unconfigured size for free.
it('blocks the unconfigured container size and prices the configured one', async () => {
const fortyOnly: Rate = {
...intercityContainerUsd,
id: 'rate-ct-40-only',
containerTypeId: 'ct-40',
} as Rate;
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
const booking = {
id: 'b-5',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
originYardId: MOJO,
destinationYardId: DIRE,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [
{ containerTypeId: 'ct-40', quantity: 2 },
{ containerTypeId: 'ct-20', quantity: 3 },
],
});
expect(result.lineItems).toHaveLength(1);
expect(result.blocked).toHaveLength(1);
expect(result.blocked[0]).toContain('rate is configured');
});
});

View File

@@ -10,11 +10,9 @@ import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository';
import {
containersPerWagon,
wagonRemainder,
} from './consolidation.service';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -139,8 +137,12 @@ export class BookingPricingService {
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
const {
lineItems: baseLines,
usedRates: baseRates,
warnings: baseWarnings,
blocked: baseBlocked,
} = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
@@ -163,8 +165,16 @@ export class BookingPricingService {
const usdAmount = mod.calculatedAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
// Derived/route-matched charges (import overweight, empty-container
// return) carry their own unit price + billing unit — bill and display
// those, not whatever the referenced rate row says.
const isDerived = mod.unitPriceUsd != null;
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
const unitUsd = isDerived
? Number(mod.unitPriceUsd)
: rate
? Number(rate.rateValue)
: usdAmount;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
// derive from total ÷ unit price (the live unit price — a count, not a
@@ -180,11 +190,11 @@ export class BookingPricingService {
// H15: bill the frozen contract surcharge rate (already in the booking
// currency) when this code has a snapshot; else keep the live amount.
const frozen = this.frozenRateByCode(
frozenRates,
mod.surchargeCode,
paymentCurrency,
);
// Derived charges skip the snapshot — import overweight prices off the
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -249,8 +259,8 @@ export class BookingPricingService {
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
warnings: [...ruleResult.warnings, ...baseWarnings],
hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked],
overweightLines,
};
}
@@ -307,8 +317,12 @@ export class BookingPricingService {
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
reeferQuantity: Number(bc.reeferQuantity ?? 0),
returnQuantity: Number(bc.returnQuantity ?? 0),
},
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
perWagon: containersPerWagonForSize(ct.sizeFt),
quantity: qty,
};
}),
@@ -363,6 +377,8 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.
@@ -452,7 +468,12 @@ export class BookingPricingService {
booking: Booking,
evalInput: BookingEvaluationInput,
frozenRates: Map<string, ContractRateSnapshot> | null = null,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
): Promise<{
lineItems: PriceLineItemDto[];
usedRates: Rate[];
warnings: string[];
blocked: string[];
}> {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
@@ -474,51 +495,86 @@ export class BookingPricingService {
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const warnings: string[] = [];
const blocked: string[] = [];
const wagonCount = await this.resolveWagonCount(booking);
for (const container of evalInput.containers) {
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
if (!rate) continue;
usedRatesMap.set(rate.id, rate);
const unitUsd = Number(rate.rateValue);
const rate = this.pickRate(
liveRates,
rateType,
container.containerTypeId,
'USD',
booking.originYardId,
booking.destinationYardId,
);
// H15: frozen contract rate for this container size, when present — its
// unitPrice is already in the booking currency (no USD→currency convert).
// It also stands on its own: a contract line prices off the agreed rate
// even when nobody configured a live rate for this leg + type yet.
const frozen = await this.frozenRateForContainer(
frozenRates,
container.containerTypeId,
paymentCurrency,
);
const label = await this.containerTypeLabel(container.containerTypeId);
if (!rate && !frozen) {
// Never price this line off another container type's (or another
// route's) rate, and never let an unpriced line through: a booking
// that ships a container type nobody configured a rate for would be
// carried for free. Hard-block instead — the customer drops the line
// or EDR configures the rate.
blocked.push(
`No ${rateType} rate is configured for ${label} on this route — ` +
`the booking cannot be priced. Remove the ${label} line or ask EDR ` +
'to configure its rate for this origin → destination.',
);
continue;
}
const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
let amount: number;
let unitAmount: number;
if (frozen) {
unitAmount = Number(frozen.unitPrice);
amount = this.amountForUnit(
rate.rateUnit,
rateUnit,
unitAmount,
container.quantity,
wagonCount,
);
} else {
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const unitUsd = Number(rate!.rateValue);
const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount);
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
}
const label = await this.containerTypeLabel(container.containerTypeId);
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
amount,
unitAmount,
unit: rate.rateUnit,
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
unit: rateUnit,
quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount),
currency: paymentCurrency,
});
}
if (lines.length === 0) {
if (lines.length === 0 && evalInput.containers.length === 0) {
// Bulk (and any booking with no container lines) still has to price off a
// rate configured for this leg — never one belonging to another route.
// Container bookings never reach this fallback: their lines price per
// container type above or stay unpriced with a warning — falling back to
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
// how a 38-container booking was invoiced 40 USD instead of 1900.
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
(r) =>
r.rateType === rateType &&
r.currency === 'USD' &&
r.status === 'LIVE' &&
r.originYardId === booking.originYardId &&
r.destinationYardId === booking.destinationYardId,
);
if (fallback) {
usedRatesMap.set(fallback.id, fallback);
@@ -554,10 +610,18 @@ export class BookingPricingService {
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
currency: paymentCurrency,
});
} else if (isBulk) {
// Same rule as container lines: bulk freight with no rate on this leg
// must not proceed unpriced.
blocked.push(
`No ${rateType} rate is configured for this route — the booking ` +
'cannot be priced. Ask EDR to configure the rate for this ' +
'origin → destination.',
);
}
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
}
/**
@@ -713,20 +777,32 @@ export class BookingPricingService {
}
}
/**
* Base freight is quoted per leg, so a rate only applies to a booking running
* the exact origin → destination it was configured for. There is deliberately
* no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
* because nobody configured Mojo yet is worse than surfacing no line at all.
* Within the leg, a rate scoped to the container type wins over one that
* covers every type.
*/
private pickRate(
rates: Rate[],
rateType: string,
containerTypeId: string,
currency: string,
originYardId: string,
destinationYardId: string,
): Rate | undefined {
const onLeg = rates.filter(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.originYardId === originYardId &&
r.destinationYardId === destinationYardId,
);
return (
rates.find(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.containerTypeId === containerTypeId,
) ??
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
onLeg.find((r) => !r.containerTypeId)
);
}

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
@@ -34,8 +33,6 @@ import {
BookingReferenceYardDto,
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
): BookingReferenceCargoTypeGroupDto[] {
@@ -110,7 +107,6 @@ export function groupContainersBySize(
name: ct.label?.trim() ? ct.label : ct.code,
code: ct.code,
is_reefer: ct.isReefer ?? false,
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
}),
),
}));
@@ -135,10 +131,7 @@ export class BookingReferenceDataService {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
await Promise.all([
this.yardsRepository.findAll({
where: {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
where: { isActive: true },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({

View File

@@ -22,14 +22,17 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
findById: jest.fn().mockResolvedValue(booking),
};
const ruleEngineService = {
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
assertNoHardBlocks: jest.fn(),
};
const contractService = {
generateContract: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const service = new BookingTransitionService(
bookingsRepository as never,
ruleEngineService as never,
{} as never, // pricingService
{} as never, // contractService
contractService as never,
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
@@ -57,7 +60,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
dutySlipUploadedToStaff: jest.fn(),
} as never, // notifier
);
return { service, bookingsRepository, ruleEngineService };
return { service, bookingsRepository, ruleEngineService, contractService };
}
it('rejects accept when validity days is missing or non-positive', async () => {
@@ -81,7 +84,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
const [id, updates] = bookingsRepository.update.mock.calls[0];
expect(id).toBe('b-1');
expect(updates).toMatchObject({
status: 'PENDING_APPROVAL',
status: 'APPROVED',
approvedByStaffId: 'staff-1',
contractValidityDays: 10,
});
@@ -96,12 +99,9 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
});
it('instantiates the approval chain when accepting', async () => {
const { service, ruleEngineService } = makeService();
it('approves outright and generates the contract (no approval chain)', async () => {
const { service, contractService } = makeService();
await service.acceptIntake('b-1', 'staff-1', 30);
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ freightType: 'CONTAINER' }),
);
expect(contractService.generateContract).toHaveBeenCalledWith('b-1');
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ConflictException } from '@nestjs/common';
import { BookingTransitionService } from './booking-transition.service';
/**
@@ -116,3 +116,98 @@ describe('BookingTransitionService — operation review', () => {
);
});
});
/**
* Export over-book gate at the customer's requestOperation step: export never
* splits, so the free-space check runs the moment the customer commits to a
* shipment day. When no single export train that day can carry the whole
* booking, `pickExportSchedule` throws and the request is refused BEFORE the
* booking moves to OPERATION_REQUEST_PENDING. Import bookings are never gated
* here (they are batched + splittable later).
*/
describe('BookingTransitionService — requestOperation export space gate', () => {
function makeService(tradeDirection: 'EXPORT' | 'IMPORT', overbook: boolean) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: 'CLEARANCE_READY',
tradeDirection,
originYardId: 'o-1',
destinationYardId: 'd-1',
totalAmount: 1000,
contractId: null,
serviceType: { code: 'RAIL_CONTAINER' },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
checkDayCompatibilityForBooking: jest
.fn()
.mockResolvedValue({ hasDeparture: true, hasCompatible: true }),
};
const bookingBatchService = {
// Over-book → the export gate rejects; otherwise it returns a schedule id.
pickExportSchedule: overbook
? jest.fn().mockRejectedValue(new ConflictException('Not enough train space'))
: jest.fn().mockResolvedValue('sched-1'),
};
const notifier = { operationRequestedToStaff: jest.fn() };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
);
return { service, bookingsRepository, bookingBatchService };
}
it('rejects an over-booked export request and does NOT advance the booking', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'EXPORT',
true,
);
await expect(
service.requestOperation('b-1', '2026-07-20T00:00:00.000Z'),
).rejects.toBeInstanceOf(ConflictException);
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('lets an export request through when a train fits the whole booking', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'EXPORT',
false,
);
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledTimes(1);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
});
it('never runs the export gate for an import request', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService(
'IMPORT',
true, // would reject IF called — proves it is not called
);
await service.requestOperation('b-1', '2026-07-20T00:00:00.000Z');
expect(bookingBatchService.pickExportSchedule).not.toHaveBeenCalled();
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
});
});

View File

@@ -7,9 +7,8 @@ import {
Logger,
Optional,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { OnEvent } from "@nestjs/event-emitter";
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './road.util';
@@ -42,6 +41,7 @@ export class BookingTransitionService {
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
@Inject(forwardRef(() => BookingContractService))
private readonly contractService: BookingContractService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
@@ -247,16 +247,6 @@ export class BookingTransitionService {
return fresh;
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(
bookingId: string,
actorId: string,
@@ -282,21 +272,33 @@ export class BookingTransitionService {
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as "CONTAINER" | "BULK",
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: "PENDING_APPROVAL",
// Bookings no longer run a multi-step approval chain — accepting the intake
// approves the booking outright and generates its contract. (The approval
// chain is a contract-only concern now; see contract-transition.service.)
await this.bookingsRepository.update(bookingId, {
status: "APPROVED",
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
// Generating the contract is best-effort: the acceptance is already
// committed, so a failure here must not roll it back. The booking stays
// APPROVED and staff can retry generation from the booking page.
try {
await this.contractService.generateContract(bookingId);
} catch (err) {
this.logger.warn(
`Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
`The booking is APPROVED — retry generation from the booking page.`,
);
}
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.accepted(fresh);
this.notifier.approved(fresh);
return fresh;
}
@@ -323,140 +325,6 @@ export class BookingTransitionService {
return fresh;
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== "PENDING") {
throw new BadRequestException(
"Approval step not found or already actioned",
);
}
const next =
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
"Approval steps must be completed in order",
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(
`Role ${requiredRole} is blocked for this step`,
);
}
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"APPROVED",
);
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === "LINE_STAFF") {
updates.status = "APPROVED_PENDING_SIGNATURE";
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === "DIRECTOR") {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === "CEO") {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone =
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = "APPROVED";
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
const fresh = await this.bookingsService.findById(generated.id);
this.notifier.approved(fresh);
return fresh;
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"PENDING_APPROVAL",
"APPROVED_PENDING_SIGNATURE",
]);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException("Approval step not found");
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
"REJECTED",
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
"REJECTION",
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: "REJECTED",
} as never);
const fresh = await this.bookingsService.findById(updated!.id);
this.notifier.rejected(fresh, reason);
return fresh;
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["CONTRACT_READY"]);
@@ -482,6 +350,22 @@ export class BookingTransitionService {
return fresh;
}
/**
* Import EDR last-mile: every handover signed + every truck departed ⇒ the
* warehouses module delivered the goods and asks the booking to complete.
* Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
*/
@OnEvent('import.handover.completed')
async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
try {
await this.complete(payload.bookingId);
} catch (err) {
this.logger.log(
`Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
);
}
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]);
@@ -973,6 +857,22 @@ export class BookingTransitionService {
}
}
// Intercity: there is no shipment-day request step — an approved booking
// goes straight to FULLY_EXECUTED, which is what the intercity ride-along
// pool keys on. Staff then accept it onto a passing train (that accept
// opens the pay window).
if (booking.tradeDirection === "DOMESTIC") {
const now = new Date();
await this.bookingsRepository.update(bookingId, {
status: "FULLY_EXECUTED",
fullyExecutedAt: now,
lockedAt: booking.lockedAt ?? now,
} as never);
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.intercityDocumentsApproved(fresh);
return fresh;
}
await this.bookingsRepository.update(bookingId, {
status: "CLEARANCE_READY",
} as never);
@@ -1036,6 +936,40 @@ export class BookingTransitionService {
);
}
// Export is FCFS and never splits — a booking must ride one train whole. So
// the free-space check belongs HERE, the moment the customer commits to a
// shipment day, not later at staff operation-accept. Blocking now stops the
// customer booking more wagons than any single export train that day can
// still carry; `exportSpaceReport` throws a 409 whose message carries the
// 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);
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
// train. So the day is only unbookable when NO export train that day has
// any room at all — reject on the day total, not on a single-train fit.
// With the flag off this stays the strict whole-booking gate.
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
const fitting = await this.bookingBatchService.fittingTrainsForDay(
scheduledBooking,
eatDay(date),
"EXPORT",
);
if (!fitting.length) {
throw new ConflictException(
"No export train on this day has space left — pick another shipment day.",
);
}
} else {
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
}
}
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
@@ -1045,6 +979,47 @@ export class BookingTransitionService {
return fresh;
}
/**
* Advisory availability for a shipment day the customer is considering — a
* planning hint for the day picker, computed but never enforced. For EXPORT it
* mirrors the real request-time gate: `fits` is whether a single open train
* that day can carry the WHOLE booking (export never splits), and `freeWagons`
* is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
* TOTAL room across the day's trains for the booking's wagon type (the batch
* engine may still split or defer a remainder), and `fits` is whether that
* total covers the booking. `trainsForDay` is false when no departure carries
* the leg — the day is unbookable regardless of space.
*/
async dayAvailabilityForBooking(
bookingId: string,
scheduledDate: string,
): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
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");
}
const day = eatDay(date);
const isExportTrain =
booking.tradeDirection === "EXPORT" &&
!isRoadService(booking.serviceType);
if (isExportTrain) {
const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
const report =
await this.bookingBatchService.exportSpaceReport(scheduledBooking);
return {
fits: report.scheduleId != null,
freeWagons: report.bestAvailable?.wagons ?? 0,
trainsForDay: report.trainsForDay && report.corridorMatched,
};
}
const { freeWagons, need, trainsForDay } =
await this.bookingBatchService.dayImportAvailability(booking, day);
return { fits: freeWagons >= need, freeWagons, trainsForDay };
}
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Two outcomes:
@@ -1222,12 +1197,9 @@ export class BookingTransitionService {
}
let nextStep: BookingNextStep | null = null;
try {
const nextPending =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE"
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
nextStep = computeNextStep(booking, nextPending);
// Bookings no longer carry an approval chain, so there is never a pending
// approval step to hint at.
nextStep = computeNextStep(booking, null);
} catch (err) {
this.logger.warn(
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,

View File

@@ -52,10 +52,8 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
RejectStepDto,
RequestChangesDto,
ReviewDocumentDto,
RequestOperationDto,
@@ -370,6 +368,31 @@ export class BookingsController {
return this.bookingsService.availableDaysForBooking(id);
}
@Get(':id/day-availability')
@ApiOperation({
summary:
'Advisory free-wagon count for a shipment day (planning hint, not enforced). ' +
'Export: whole-booking fit + largest single-train leftover. ' +
'Import/domestic: total room across the day for the booking\'s wagon type.',
})
async dayAvailability(
@Param('id', ParseUUIDPipe) id: string,
@Query('date') date: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView)
) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
return this.transitionService.dayAvailabilityForBooking(id, date);
}
@Get(':id/mile-summary')
@ApiOperation({
summary: 'First/last-mile operational summary for a booking (customer-safe)',
@@ -413,18 +436,26 @@ export class BookingsController {
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
@ApiOperation({
summary:
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
})
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
@Query('copies') copies?: string,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const extraCopyIndexes = (copies ?? '')
.split(',')
.map((n) => Number(n.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
@@ -457,6 +488,20 @@ export class BookingsController {
return this.customerTruckService.addTruck(id, dto);
}
@Post(':id/customer-trucks/bulk')
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
async bulkAddCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
}
@Patch(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
async updateCustomerTruck(
@@ -998,47 +1043,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/approve")
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: "Approve one approval step in sequence" })
async approveStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/approval-steps/:stepId/reject")
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: "Reject at approval step" })
async rejectStep(
@Param("id", ParseUUIDPipe) id: string,
@Param("stepId", ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/contract/generate")
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: "Generate contract PDF from template" })

View File

@@ -30,7 +30,6 @@ import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
@@ -47,6 +46,7 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRateScheduleBuilder } from "../../contracts/contract-rate-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
@@ -59,7 +59,6 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
Booking,
BookingContainer,
BookingCargoModifier,
BookingApprovalStep,
BookingDocumentReview,
BookingRateSnapshot,
BookingReviewNote,
@@ -106,6 +105,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
ContractRateScheduleBuilder,
ContractRendererService,
ContractPdfService,
CustomerTruckAssignmentsRepository,

View File

@@ -4,11 +4,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
BookingDocumentReview,
@@ -113,7 +113,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'oy')
.leftJoinAndSelect('booking.destinationYard', 'dy')
.leftJoinAndSelect('booking.shippingLine', 'sl')
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
@@ -149,7 +148,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
for (const item of containers) {
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
// A per-line breakdown can never exceed the line's own quantity.
@@ -179,7 +178,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
async calculateWagonCount(bookingId: string): Promise<number> {
const result = await this.dataSource
.createQueryBuilder()
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
.select(
'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
'total',
)
.from(BookingContainer, 'bc')
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
.where('bc.booking_id = :bookingId', { bookingId })
@@ -431,58 +433,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
bookingId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
bookingId: string,
stepId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
});
}
/** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
const next = await this.findNextPendingApprovalStep(bookingId);
if (!next || next.requiredRole !== requiredRole) return null;
return next;
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
remarks?: string,
): Promise<void> {
await this.dataSource.getRepository(BookingApprovalStep).update(stepId, {
status,
actionedByStaffId: actorId,
actionedAt: new Date(),
remarks,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(bookingId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(BookingApprovalStep).count({
where: { bookingId, status: 'PENDING' },
});
return pending === 0;
}
// ── Clearance document reviews ────────────────────────────────────────────
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
@@ -669,7 +619,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
@@ -718,7 +667,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join the entity by id and select just the
@@ -1283,6 +1231,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
* one query instead of one per schedule (batch monitoring board). */
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
if (!scheduleIds.length) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
@@ -1338,6 +1303,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({
where: { id: In(bookingIds) },
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
// multiply rows badly in a single join (hot path for every allocation
// preview / assignment validation).
relationLoadStrategy: 'query',
relations: {
company: true,
originYard: true,

View File

@@ -12,12 +12,12 @@ import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
@@ -142,8 +142,21 @@ export class BookingsService {
return this.findById(bookingId);
}
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
'Original 1 (for Issuing Carrier)',
'Original 2 (for Consignee)',
'Original 3 (for Shipper)',
'Copy 4 (Delivery Receipt)',
'Copy 5 (Extra Copy)',
'Copy 6 (Extra Copy)',
'Copy 7 (Extra Copy)',
'Copy 8 (for Agent)',
] as const;
async customerTruckFreightOrderCopies(
bookingId: string,
extraCopyIndexes: number[] = [],
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
@@ -171,7 +184,12 @@ export class BookingsService {
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
// whatever the customer ticked (indexes into the fixed catalog).
const extraCopies = [...new Set(extraCopyIndexes)]
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
.filter(Boolean);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
// Chromium when available; otherwise the styled tabular fallback (never the
// generic text dump — the freight order is an outward-facing gate document).
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
@@ -268,6 +286,7 @@ export class BookingsService {
arrivedAt: string | null;
containers: string | null;
}>,
extraCopies: string[] = [],
): string {
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
const assignedAt = booking.customerTruckAssignedAt
@@ -386,6 +405,7 @@ export class BookingsService {
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
${extraCopies.map((label) => copy(label)).join('')}
</body>
</html>`;
}
@@ -422,6 +442,8 @@ export class BookingsService {
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
@@ -438,7 +460,7 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
};
}),
);
@@ -467,6 +489,8 @@ export class BookingsService {
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId ?? null,
destinationYardId: dto.destinationYardId ?? null,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
@@ -634,12 +658,9 @@ export class BookingsService {
);
}
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// A customer can only book once their company has been approved.
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create bookings yet.",
);
}
// A customer can only book once their company has been approved; the
// helper names the real status (suspended/blacklisted) when it isn't.
this.companiesService.assertCompanyActiveFor(company, 'bookings');
companyId = company.id;
}
@@ -745,21 +766,13 @@ export class BookingsService {
);
companyProfileId = profile.id;
} else if (companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
// No explicit profile pin: resolve from the booking's trade direction
// (import→importer, export→exporter; otherwise the first profile). A
// forwarder booking sends dto.companyProfileId and takes the branch above.
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
tradeDirection,
fallbackType,
);
// A customer booking under their own account may only do so once the
@@ -799,6 +812,8 @@ export class BookingsService {
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
@@ -1009,6 +1024,8 @@ export class BookingsService {
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
originYardId: dto.originYardId ?? existing.originYardId,
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});
@@ -1067,9 +1084,6 @@ export class BookingsService {
await this.companiesService.resolveCompanyProfileIdForBooking(
existing.companyId,
tradeDirection,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
@@ -1227,9 +1241,9 @@ export class BookingsService {
/**
* Batched version of the findById flag: marks each page item whose booking
* has a generated-but-unsigned SELF_HAUL handover, so list rows (portal
* dashboard) can show "Approve delivery" for exactly the generated→signed
* window. One query for the whole page.
* has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
* rows (portal dashboard) can show "Approve delivery" for exactly the
* generated→signed window. One query for the whole page.
*/
private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
const ids = bookings.map((b) => b.id);
@@ -1238,8 +1252,7 @@ export class BookingsService {
`SELECT DISTINCT booking_id AS "bookingId"
FROM freight.booking_handovers
WHERE booking_id = ANY($1::uuid[])
AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'`,
AND signed_at IS NULL AND deleted_at IS NULL`,
[ids],
);
const pending = new Set(rows.map((r) => r.bookingId));
@@ -1391,15 +1404,6 @@ export class BookingsService {
}
}
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
@@ -1582,14 +1586,12 @@ export class BookingsService {
schedule?.status ?? null;
}
// A generated-but-unsigned SELF_HAUL handover means the customer must approve
// delivery from the portal (booking-based, one per booking). EDR last-mile
// handovers are per delivering truck and signed by the receiver at the door,
// so they never surface the portal "Approve delivery" action.
// A generated-but-unsigned handover means the customer must approve delivery
// from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
// per delivering truck (generated on truck exit), signed one by one.
const [pendingHandover] = await this.dataSource.query(
`SELECT 1 FROM freight.booking_handovers
WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
AND mile_type = 'SELF_HAUL'
LIMIT 1`,
[id],
);

View File

@@ -1,7 +1,10 @@
import {
clearanceSettingCode,
clearanceOutputSettingCode,
clearanceCodesForBooking,
INTERCITY_DOCUMENTS_SETTING_CODE,
} from './clearance.util';
import type { Booking } from './entities/booking.entity';
describe('clearance.util — clearanceSettingCode', () => {
it('resolves import container with/without customs', () => {
@@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => {
);
});
it('returns null for DOMESTIC (no clearance gate)', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => {
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe(
INTERCITY_DOCUMENTS_SETTING_CODE,
);
});
});
describe('clearance.util — clearanceCodesForBooking (intercity)', () => {
const base = {
tradeDirection: 'DOMESTIC',
freightType: 'CONTAINER',
serviceType: null,
customsClearingEnabled: false,
};
it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => {
const general = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'GENERAL',
} as unknown as Booking);
expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
expect(general.outputCode).toBeNull();
const direct = clearanceCodesForBooking({
...base,
contractId: null,
contractKind: null,
} as unknown as Booking);
expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE);
});
it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => {
const drawdown = clearanceCodesForBooking({
...base,
contractId: 'c1',
contractKind: 'ONE_TIME',
} as unknown as Booking);
expect(drawdown.inputCode).toBeNull();
expect(drawdown.outputCode).toBeNull();
});
});

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