mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
18
.gitignore
vendored
18
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal file
39
apps/edr-freight-api/src/common/export-received-gate.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal file
50
apps/edr-freight-api/src/common/export-received-gate.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 step’s 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
13
apps/edr-freight-api/src/common/grn.util.ts
Normal file
13
apps/edr-freight-api/src/common/grn.util.ts
Normal 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}`;
|
||||
}
|
||||
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal file
61
apps/edr-freight-api/src/common/mile-financials.util.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal file
52
apps/edr-freight-api/src/common/mile-haulage.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal file
49
apps/edr-freight-api/src/common/mile-haulage.util.ts
Normal 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 customer’s 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.';
|
||||
@@ -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)])),
|
||||
);
|
||||
|
||||
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal 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
|
||||
)`;
|
||||
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal file
159
apps/edr-freight-api/src/common/truck-load.util.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal file
148
apps/edr-freight-api/src/common/truck-load.util.ts
Normal 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());
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,5 +20,9 @@
|
||||
{{/each}}
|
||||
</ol>
|
||||
{{/if}}
|
||||
{{#if (eq id "pricing")}}
|
||||
<h3>Rate Schedule</h3>
|
||||
{{> rate_schedule}}
|
||||
{{/if}}
|
||||
</section>
|
||||
{{/each}}
|
||||
|
||||
@@ -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 & Fees</th></tr>
|
||||
{{#each rateSchedule.surcharges}}
|
||||
<tr>
|
||||
<td>{{route}}</td>
|
||||
<td>{{cargo}}</td>
|
||||
<td>{{currency}} {{amount}} {{unit}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
@@ -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 ───────────────────────────── --}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;`);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal file
62
apps/edr-freight-api/src/modules/auth/account.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal file
226
apps/edr-freight-api/src/modules/auth/account.service.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal file
60
apps/edr-freight-api/src/modules/auth/dto/account.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
|
||||
27
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal file
27
apps/edr-freight-api/src/modules/auth/mask-target.util.ts
Normal 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 ");
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user