mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -11,7 +11,7 @@ One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight
|
||||
node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output
|
||||
node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched)
|
||||
node .claude/skills/edr-db/query.cjs columns <table> # freight.<table> column list
|
||||
node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first)
|
||||
node .claude/skills/edr-db/query.cjs migrations [like] # migration rows, newest first (freight.migrations + iam.typeorm_migrations)
|
||||
node .claude/skills/edr-db/query.cjs drift <table> # bare column names, for diffing vs the entity
|
||||
```
|
||||
|
||||
@@ -31,7 +31,11 @@ defaulting to the shared dev database (`edr_dev`).
|
||||
(`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`,
|
||||
and non-idempotent statements have double-run here before.
|
||||
- Timestamps for new migrations: must be unique across `src/migrations/` AND
|
||||
higher than `SELECT max(timestamp) FROM public.migrations`.
|
||||
higher than `SELECT max(timestamp) FROM freight.migrations`.
|
||||
- Freight and IAM keep separate histories: `freight.migrations` for
|
||||
`apps/edr-freight-api/src/migrations/*`, `iam.typeorm_migrations` for the
|
||||
`@tria-plc/iamapi-common` migrations. `public.migrations` is the pre-split
|
||||
table, left in place for rollback — never write to it.
|
||||
|
||||
## Diagnosing a pasted 400/500 (the recurring loop)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table)
|
||||
* node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only
|
||||
* node .claude/skills/edr-db/query.cjs columns <table> list freight.<table> columns
|
||||
* node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows
|
||||
* node .claude/skills/edr-db/query.cjs migrations [like] freight/iam migration rows
|
||||
* node .claude/skills/edr-db/query.cjs drift <table> columns vs entity check helper
|
||||
*
|
||||
* Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling
|
||||
@@ -52,12 +52,27 @@ async function main() {
|
||||
console.table(r.rows);
|
||||
} else if (first === 'migrations') {
|
||||
const like = rest[0] ? `%${rest[0]}%` : '%';
|
||||
const r = await c.query(
|
||||
`SELECT id, timestamp, name FROM public.migrations
|
||||
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
|
||||
[like],
|
||||
);
|
||||
console.table(r.rows);
|
||||
// Histories are split per owner: freight.migrations (this app) and
|
||||
// iam.typeorm_migrations (@tria-plc/iamapi-common). public.migrations is the
|
||||
// pre-split table, kept for rollback — read it only if the split has not
|
||||
// been applied to this DB yet.
|
||||
const sources = [
|
||||
['freight', 'freight.migrations'],
|
||||
['iam', 'iam.typeorm_migrations'],
|
||||
['legacy', 'public.migrations'],
|
||||
];
|
||||
const rows = [];
|
||||
for (const [owner, table] of sources) {
|
||||
const present = await c.query(`SELECT to_regclass($1) IS NOT NULL AS ok`, [table]);
|
||||
if (!present.rows[0].ok) continue;
|
||||
const r = await c.query(
|
||||
`SELECT id, timestamp, name FROM ${table}
|
||||
WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`,
|
||||
[like],
|
||||
);
|
||||
rows.push(...r.rows.map((row) => ({ owner, ...row })));
|
||||
}
|
||||
console.table(rows);
|
||||
} else if (first === 'drift') {
|
||||
// Quick drift signal: DB columns for the table. Compare by eye against
|
||||
// the entity's @Column names; a recorded-but-absent column = drift.
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -49,3 +49,9 @@ test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
RUNNING_LOCALLY.md
|
||||
|
||||
# Generated per-shard compose file for the integration suite (it.mjs).
|
||||
integration/.it-shards.yaml
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
# @tria-plc/auditlog's client interceptor stamps every AuditLog row's
|
||||
# `application` from this env var directly, bypassing MezgebModule.forRoot's
|
||||
# applicationName option (package quirk). audit.controller.ts reads the same
|
||||
# var when filtering reads, so this can be anything as long as it's set.
|
||||
APPLICATION_NAME=freight-api
|
||||
# Also required for @tria-plc/auditlog: its producer (AuditClientModule)
|
||||
# reads the RMQ URL at package IMPORT time, before MezgebModule.forRoot's
|
||||
# rmqUrl option ever runs, so only an env var reaches it — an in-code
|
||||
# override is too late. Without this, audit events are silently dropped
|
||||
# (no error, nothing published). Point it at whatever broker/vhost your
|
||||
# RabbitMQ actually has a user provisioned on.
|
||||
RABBITMQ_URL=amqp://localhost:5672
|
||||
# GT06 GPS tracker TCP listener port (raw TCP, must be reachable by tracker SIMs). 0 disables.
|
||||
GT06_TCP_PORT=5023
|
||||
DB_HOST=localhost
|
||||
@@ -30,6 +42,12 @@ FREIGHT_PORTAL_URL=http://localhost:5173
|
||||
# Point these at the freight portal's public payment result routes.
|
||||
PAYMENT_RETURN_URL=http://localhost:5173/payment/success
|
||||
PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure
|
||||
|
||||
# Drain tail (minutes) added to every booking pay window before anything expires:
|
||||
# settlement is asynchronous, so a payment made in the window's last seconds lands
|
||||
# after the deadline. Nothing is expired, no wagons are resold and no window cycle
|
||||
# concludes until the tail passes. Defaults to 5 when unset.
|
||||
FREIGHT_PAYMENT_DRAIN_MINUTES=5
|
||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||
JWT_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
@@ -92,10 +110,10 @@ FAYDA_PRIVATE_KEY_BASE64=
|
||||
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
|
||||
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
|
||||
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
|
||||
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/fayda/callback
|
||||
# OAuth redirect_uri for the customer portal (its own origin — must also be
|
||||
# registered with eSignet). Defaults to FAYDA_WEB_REDIRECT_URI when unset.
|
||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/callback
|
||||
FAYDA_PORTAL_REDIRECT_URI=http://localhost:5173/fayda/callback
|
||||
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
|
||||
FAYDA_SCOPE=openid profile email phone address
|
||||
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
|
||||
|
||||
@@ -21,7 +21,7 @@ flowchart TD
|
||||
S0(["Customer visits portal"]):::start
|
||||
S0 --> S1["Signup via IAM<br/>GET /auth/check-availability @Public<br/>POST /otp/send + /otp/verify (P)"]:::port
|
||||
S1 --> S2{"Identity proofing<br/>(VeriFayda)?"}:::dec
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"Yes"| S3["POST /fayda/verification/start →<br/>/fayda/callback → /complete<br/>upsert iam.users (verified_by=fayda) (P)"]:::port
|
||||
S2 -->|"No"| S4
|
||||
S3 --> S4["POST /companies/onboarding/start<br/>draft company (placeholder TIN, PENDING) (P)"]:::port
|
||||
S4 --> S4b["Wizard: PATCH /profile, /onboarding-step,<br/>upload license + docs<br/>GET /onboarding/requirements (P)"]:::port
|
||||
|
||||
@@ -131,7 +131,7 @@ sequenceDiagram
|
||||
`HasActiveDelegationGuard` as **global `APP_GUARD`s** — *every* route is JWT-protected unless it
|
||||
carries `@Public()`. Fine-grained `FreightPermissionGuard([perm])` decorators add permission checks
|
||||
on staff routes. Explicitly **public** endpoints: `GET /api/files/:fileId`, `POST /api/otp/{send,verify}`,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/callback` endpoints,
|
||||
`GET /api/auth/check-availability`, the `fayda/verification/*` + `/fayda/callback` endpoints,
|
||||
`GET /api/payments/{checkout,receipt/:orderId}`, and the service-to-service `POST /api/internal/payments/mark-paid`.
|
||||
Real login / JWT issuance lives in the **external IAM package**, not this repo. (Note: `@edr/api-common`'s
|
||||
`@Public` and `@tria-plc/api-common`'s `@IsPublic` both set the same `"isPublic"` metadata key the guard reads.)
|
||||
@@ -288,7 +288,7 @@ flowchart TD
|
||||
chk --> otp["POST /otp/send + /otp/verify (P) @Public"]
|
||||
otp --> fayda{"Identity proofing?"}
|
||||
fayda -->|"VeriFayda 2.0"| fstart["POST /fayda/verification/start<br/>→ eSignet authorize URL"]
|
||||
fstart --> fcb["Fayda redirect → GET /callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fstart --> fcb["Fayda redirect → GET /fayda/callback (ack)<br/>→ GET /fayda/verification/complete<br/>(PKCE code exchange → upsert iam.users)"]
|
||||
fcb --> onb
|
||||
fayda -->|"skip"| onb
|
||||
|
||||
@@ -313,7 +313,7 @@ drives the required document set. Booking guards elsewhere `403` if the acting p
|
||||
| POST | `/api/fayda/verification/start` | start eSignet session (PKCE) | `@Public` + OptionalJwt | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/complete` | code→identity, upsert `iam.users` | `@Public` | (B) verifayda.service |
|
||||
| GET | `/api/fayda/verification/status` | current user's Fayda link | JwtGuard | — |
|
||||
| GET | `/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET | `/fayda/callback` | passive Fayda redirect ack (no `/api`) | `@Public` | popup postMessage |
|
||||
| GET·PUT | `/api/me/signature` | reusable signature (MinIO, base64) | JwtGuard | (P)(B) signatures.service |
|
||||
| GET | `/api/test_user1` · `/api/test_user2` | permission-guard demo | `PermissionGuard` | (B) demo pages |
|
||||
| GET | `/api/companies/getInfo` · `/profile` · `/dashboard` | company info / KPIs | JwtGuard | (P) companies.service |
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule } from "@tria-plc/iamapi-common";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import { MezgebModule } from "@tria-plc/auditlog";
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
@@ -46,6 +47,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati
|
||||
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 { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-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";
|
||||
@@ -65,7 +67,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 { 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";
|
||||
@@ -89,6 +91,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
import { RoutesModule } from "./modules/routes/routes.module";
|
||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||
import { OverviewModule } from "./modules/overview/overview.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||
@@ -100,12 +103,18 @@ import { ProcurementModule } from "./modules/procurement/procurement.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { AuditModule } from "./modules/audit/audit.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
|
||||
if (!process.env.APPLICATION_NAME) {
|
||||
process.env.APPLICATION_NAME = "freight";
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -153,6 +162,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
// Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog).
|
||||
// Must come after TypeOrmModule above so it picks up this app's DataSource.
|
||||
// rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL
|
||||
// does: the dev broker only provisions the `edr` user on the `payment`
|
||||
// vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset
|
||||
// RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED).
|
||||
MezgebModule.forRoot({
|
||||
applicationName: "freight-api",
|
||||
rmqUrl:
|
||||
process.env.RABBITMQ_URL ??
|
||||
process.env.PAYMENT_RABBITMQ_URL ??
|
||||
"amqp://localhost:5672",
|
||||
}),
|
||||
SharedAuthModule,
|
||||
IamModule.forRoot({
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
@@ -191,6 +213,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
SupportChatModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
@@ -207,6 +230,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
RoutesModule,
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
ReportsModule,
|
||||
UserTradeAccessModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
@@ -218,17 +242,19 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
GpsTrackingModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
LastMileRequestsModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
FreightPositionsSeeder,
|
||||
FileUploadSettingsSeeder,
|
||||
YardFacilitiesSeeder,
|
||||
// YardFacilitiesSeeder,
|
||||
FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — providers commented out (imports/injection/run too):
|
||||
// DemoUsersSeeder,
|
||||
@@ -255,7 +281,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly freightPositionsSeeder: FreightPositionsSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
// private readonly yardFacilitiesSeeder: YardFacilitiesSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
// Disabled seeds — injections commented out (imports/provider/run too):
|
||||
// private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
@@ -304,7 +330,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
// Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama,
|
||||
// Dire Dawa). Idempotent; creates no yards.
|
||||
await this.yardFacilitiesSeeder.run();
|
||||
// await this.yardFacilitiesSeeder.run();
|
||||
|
||||
// Dropdown settings are not seeded on boot; run them with
|
||||
// `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts).
|
||||
@@ -335,9 +361,11 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
consumer.apply(LoginAudienceMiddleware).forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
consumer
|
||||
.apply(LoginAudienceMiddleware)
|
||||
.forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
121
apps/edr-freight-api/src/common/last-mile-charge.util.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { computeLastMileCharge } from './last-mile-charge.util';
|
||||
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
|
||||
const rate = (over: Partial<Rate>): Rate =>
|
||||
({
|
||||
appliesTo: 'LAST_MILE',
|
||||
status: 'LIVE',
|
||||
currency: 'ETB',
|
||||
trigger: 'ALWAYS',
|
||||
rateType: 'LAST_MILE',
|
||||
...over,
|
||||
}) as Rate;
|
||||
|
||||
const band20a = rate({
|
||||
rateUnit: 'PER_KM',
|
||||
rateValue: 1800,
|
||||
minKm: 0,
|
||||
maxKm: 30,
|
||||
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||||
});
|
||||
const band20b = rate({
|
||||
rateUnit: 'PER_KM',
|
||||
rateValue: 1500,
|
||||
minKm: 30,
|
||||
maxKm: null,
|
||||
containerType: { sizeFt: 20 } as Rate['containerType'],
|
||||
});
|
||||
const band40a = rate({
|
||||
rateUnit: 'PER_KM',
|
||||
rateValue: 2200,
|
||||
minKm: 0,
|
||||
maxKm: 30,
|
||||
containerType: { sizeFt: 40 } as Rate['containerType'],
|
||||
});
|
||||
const bulkRate = rate({ rateUnit: 'PER_TON_KM', rateValue: 25 });
|
||||
|
||||
describe('computeLastMileCharge', () => {
|
||||
it('prices containers per band × size × quantity', () => {
|
||||
const charge = computeLastMileCharge({
|
||||
freightType: 'CONTAINER',
|
||||
tons: 0,
|
||||
km: 13,
|
||||
containers: [
|
||||
{ sizeLabel: '20DC', qty: 5 },
|
||||
{ sizeLabel: '40HC', qty: 1 },
|
||||
],
|
||||
liveRates: [band20a, band20b, band40a],
|
||||
});
|
||||
// 13 × 1800 × 5 + 13 × 2200 × 1
|
||||
expect(charge).toMatchObject({ mode: 'CONTAINER', total: 117000 + 28600, currency: 'ETB' });
|
||||
expect(charge!.lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('band boundary is half-open: km = 30 falls in the 30+ band', () => {
|
||||
const charge = computeLastMileCharge({
|
||||
freightType: 'CONTAINER',
|
||||
tons: 0,
|
||||
km: 30,
|
||||
containers: [{ sizeLabel: '20DC', qty: 1 }],
|
||||
liveRates: [band20a, band20b],
|
||||
});
|
||||
expect(charge!.lines[0].unitRate).toBe(1500);
|
||||
expect(charge!.total).toBe(30 * 1500);
|
||||
});
|
||||
|
||||
it('returns null when a size has no matching band', () => {
|
||||
const charge = computeLastMileCharge({
|
||||
freightType: 'CONTAINER',
|
||||
tons: 0,
|
||||
km: 50,
|
||||
containers: [{ sizeLabel: '40HC', qty: 2 }],
|
||||
liveRates: [band40a], // 40ft only covers 0–30
|
||||
});
|
||||
expect(charge).toBeNull();
|
||||
});
|
||||
|
||||
it('prices bulk as tons × km × rate', () => {
|
||||
const charge = computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 60,
|
||||
km: 26,
|
||||
containers: [],
|
||||
liveRates: [bulkRate],
|
||||
});
|
||||
expect(charge).toMatchObject({ mode: 'BULK', total: 60 * 26 * 25, currency: 'ETB' });
|
||||
});
|
||||
|
||||
it('returns null on mixed currencies, unknown km, and uncovered freight types', () => {
|
||||
const usd40 = rate({ ...band40a, currency: 'USD' });
|
||||
expect(
|
||||
computeLastMileCharge({
|
||||
freightType: 'CONTAINER',
|
||||
tons: 0,
|
||||
km: 10,
|
||||
containers: [
|
||||
{ sizeLabel: '20DC', qty: 1 },
|
||||
{ sizeLabel: '40HC', qty: 1 },
|
||||
],
|
||||
liveRates: [band20a, usd40],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
computeLastMileCharge({
|
||||
freightType: 'BULK',
|
||||
tons: 10,
|
||||
km: 0,
|
||||
containers: [],
|
||||
liveRates: [bulkRate],
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
computeLastMileCharge({
|
||||
freightType: 'BREAK_BULK',
|
||||
tons: 10,
|
||||
km: 10,
|
||||
containers: [],
|
||||
liveRates: [bulkRate],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
202
apps/edr-freight-api/src/common/last-mile-charge.util.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { Rate } from '../modules/rule-engine/entities/rate.entity';
|
||||
import { bookingContainerSizes } from './truck-load.util';
|
||||
|
||||
/** One priced line of a rule-based last-mile charge. */
|
||||
export interface LastMileChargeLine {
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitRate: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** A fully-resolved rule-based last-mile charge. */
|
||||
export interface LastMileCharge {
|
||||
mode: 'BULK' | 'CONTAINER';
|
||||
total: number;
|
||||
currency: string;
|
||||
lines: LastMileChargeLine[];
|
||||
}
|
||||
|
||||
/** What the last-mile leg is hauling, in the shape the rate rules price. */
|
||||
export interface LastMileShipmentShape {
|
||||
freightType: string | null;
|
||||
tons: number;
|
||||
containers: Array<{ sizeLabel: string; qty: number }>;
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Price a last-mile leg off the LIVE rate rules. Pure — pass the live rates in.
|
||||
*
|
||||
* BULK: one PER_TON_KM rate → price = tons × km × rate.
|
||||
* CONTAINER: per container size, the PER_KM rate whose distance band holds the
|
||||
* km (bands are half-open [minKm, maxKm), NULL maxKm = open-ended) → price =
|
||||
* km × rate × quantity, summed across sizes.
|
||||
*
|
||||
* Returns null whenever the rules don't fully cover the shipment (no rate, a
|
||||
* container size without a matching band, mixed currencies, km/tons unknown) —
|
||||
* callers keep their existing pricing as the fallback. Never throws.
|
||||
*/
|
||||
export function computeLastMileCharge(input: {
|
||||
freightType: string | null;
|
||||
tons: number;
|
||||
km: number;
|
||||
containers: Array<{ sizeLabel: string; qty: number }>;
|
||||
/** LIVE rates with the containerType relation loaded (findLiveRatesDetailed). */
|
||||
liveRates: Rate[];
|
||||
}): LastMileCharge | null {
|
||||
const { freightType, tons, km, containers, liveRates } = input;
|
||||
if (!km || km <= 0) return null;
|
||||
|
||||
const candidates = liveRates.filter(
|
||||
(rate) => rate.appliesTo === 'LAST_MILE' && rate.status === 'LIVE',
|
||||
);
|
||||
|
||||
if (freightType === 'BULK') {
|
||||
if (!tons || tons <= 0) return null;
|
||||
const rate = candidates.find((r) => r.rateUnit === 'PER_TON_KM');
|
||||
if (!rate) return null;
|
||||
const unitRate = Number(rate.rateValue);
|
||||
const amount = round2(tons * km * unitRate);
|
||||
return {
|
||||
mode: 'BULK',
|
||||
total: amount,
|
||||
currency: rate.currency,
|
||||
lines: [
|
||||
{
|
||||
description: `Last-mile bulk delivery — ${tons} t × ${km} km × ${unitRate}/t·km`,
|
||||
quantity: tons,
|
||||
unitRate,
|
||||
amount,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (freightType === 'CONTAINER') {
|
||||
if (!containers.length) return null;
|
||||
const lines: LastMileChargeLine[] = [];
|
||||
const currencies = new Set<string>();
|
||||
for (const group of containers) {
|
||||
const rate = candidates.find(
|
||||
(r) =>
|
||||
r.rateUnit === 'PER_KM' &&
|
||||
r.minKm !== null &&
|
||||
r.minKm !== undefined &&
|
||||
r.containerType?.sizeFt !== null &&
|
||||
r.containerType?.sizeFt !== undefined &&
|
||||
group.sizeLabel.includes(String(r.containerType.sizeFt)) &&
|
||||
Number(r.minKm) <= km &&
|
||||
(r.maxKm === null || r.maxKm === undefined || km < Number(r.maxKm)),
|
||||
);
|
||||
// A size the rules don't cover means the rule set can't price this job.
|
||||
if (!rate) return null;
|
||||
const unitRate = Number(rate.rateValue);
|
||||
const amount = round2(km * unitRate * group.qty);
|
||||
currencies.add(rate.currency);
|
||||
lines.push({
|
||||
description: `Last-mile delivery — ${group.qty} × ${group.sizeLabel} container, ${km} km @ ${unitRate}/km`,
|
||||
quantity: group.qty,
|
||||
unitRate,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
// A charge can't mix birr and dollar lines on one invoice.
|
||||
if (currencies.size !== 1) return null;
|
||||
return {
|
||||
mode: 'CONTAINER',
|
||||
total: round2(lines.reduce((sum, line) => sum + line.amount, 0)),
|
||||
currency: [...currencies][0],
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based charge for an operational last-mile record: prices what its
|
||||
* trucks actually haul (last_mile_vehicle_containers / weighed net tons)
|
||||
* against the given km. Shared by setDistances (writes remainingPayment) and
|
||||
* DELIVERY_FEE invoicing so the two never disagree on the math. Null = the
|
||||
* rules don't cover this job — callers keep the per-vehicle price/km path.
|
||||
*/
|
||||
export async function ruleBasedLastMileCharge(
|
||||
dataSource: DataSource,
|
||||
liveRates: Rate[],
|
||||
lastMileId: string,
|
||||
km: number,
|
||||
): Promise<LastMileCharge | null> {
|
||||
if (!km || km <= 0) return null;
|
||||
const [record]: Array<{ bookingId: string }> = await dataSource.query(
|
||||
`SELECT booking_id AS "bookingId"
|
||||
FROM freight.last_mile
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[lastMileId],
|
||||
);
|
||||
if (!record) return null;
|
||||
|
||||
const containerRows: Array<{ containerNumber: string }> = await dataSource.query(
|
||||
`SELECT container_number AS "containerNumber"
|
||||
FROM freight.last_mile_vehicle_containers
|
||||
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
|
||||
[lastMileId],
|
||||
);
|
||||
const shape = await lastMileShipmentShape(
|
||||
dataSource,
|
||||
record.bookingId,
|
||||
containerRows.map((r) => r.containerNumber),
|
||||
);
|
||||
|
||||
// Bulk: bill the weighed tonnage on this record's trucks when known,
|
||||
// falling back to the booking's declared VGM total.
|
||||
const [tonsRow]: Array<{ tons: string | null }> = await dataSource.query(
|
||||
`SELECT SUM(net_weight_tons) AS "tons"
|
||||
FROM freight.last_mile_vehicle_assignments
|
||||
WHERE last_mile_id = $1 AND deleted_at IS NULL`,
|
||||
[lastMileId],
|
||||
);
|
||||
const weighedTons = Number(tonsRow?.tons ?? 0);
|
||||
|
||||
return computeLastMileCharge({
|
||||
...shape,
|
||||
tons: weighedTons > 0 ? weighedTons : shape.tons,
|
||||
km,
|
||||
liveRates,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a booking's shipment shape for the charge resolver: freight type, bulk
|
||||
* tonnage, and the container numbers grouped into size × quantity.
|
||||
*/
|
||||
export async function lastMileShipmentShape(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
containerNumbers: string[],
|
||||
): Promise<LastMileShipmentShape> {
|
||||
const [booking]: Array<{ freightType: string | null; tons: string | null }> =
|
||||
await dataSource.query(
|
||||
`SELECT freight_type AS "freightType", cargo_total_weight_vgm AS "tons"
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const sizes = await bookingContainerSizes(
|
||||
dataSource,
|
||||
bookingId,
|
||||
containerNumbers.map((n) => n.trim().toUpperCase()),
|
||||
);
|
||||
const bySize = new Map<string, number>();
|
||||
for (const size of sizes) {
|
||||
if (!size) continue;
|
||||
bySize.set(size, (bySize.get(size) ?? 0) + 1);
|
||||
}
|
||||
return {
|
||||
freightType: booking?.freightType ?? null,
|
||||
tons: Number(booking?.tons ?? 0),
|
||||
containers: [...bySize.entries()].map(([sizeLabel, qty]) => ({ sizeLabel, qty })),
|
||||
};
|
||||
}
|
||||
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { haversineKm } from './mile-distance.util';
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('is zero for the same point', () => {
|
||||
expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0);
|
||||
});
|
||||
|
||||
it('matches one degree of longitude at the equator (~111.19 km)', () => {
|
||||
expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1);
|
||||
});
|
||||
|
||||
it('Sebeta yard → Indode yard is roughly 26 km', () => {
|
||||
const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913);
|
||||
expect(km).toBeGreaterThan(20);
|
||||
expect(km).toBeLessThan(35);
|
||||
});
|
||||
});
|
||||
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
/** Great-circle distance in km between two WGS84 points (haversine). */
|
||||
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * 6371 * Math.asin(Math.sqrt(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated road-leg distance for a booking's first/last mile: straight-line km
|
||||
* from the yard (freight.yard_locations) to the customer's pickup/delivery GPS
|
||||
* point on the booking. FIRST = origin yard → pickup point, LAST = destination
|
||||
* yard → delivery point. Null when either end has no coordinates.
|
||||
*
|
||||
* ponytail: haversine straight-line, not road routing — plug a routing API in
|
||||
* here if real road km is ever required.
|
||||
*/
|
||||
export async function estimateMileKm(
|
||||
dataSource: DataSource,
|
||||
bookingId: string,
|
||||
mile: 'FIRST' | 'LAST',
|
||||
): Promise<number | null> {
|
||||
const [row] = await dataSource.query(
|
||||
mile === 'LAST'
|
||||
? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng,
|
||||
l.latitude AS yard_lat, l.longitude AS yard_lng
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yard_locations l
|
||||
ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`
|
||||
: `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng,
|
||||
l.latitude AS yard_lat, l.longitude AS yard_lng
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yard_locations l
|
||||
ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) {
|
||||
return null;
|
||||
}
|
||||
const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng));
|
||||
return Math.round(km * 100) / 100;
|
||||
}
|
||||
@@ -24,12 +24,13 @@ export default registerAs("app", () => ({
|
||||
},
|
||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||
cbeExchange: {
|
||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
||||
/** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */
|
||||
scrapeUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
// No fallback env var: the fallback lives in freight.exchange_settings,
|
||||
// maintained by the backoffice and by write-back on every successful fetch.
|
||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,6 +56,11 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i
|
||||
import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity";
|
||||
import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity";
|
||||
import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity";
|
||||
import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog";
|
||||
|
||||
// @tria-plc/auditlog's entities live in node_modules, same as the iam ones —
|
||||
// the glob below only matches this app's own src/**/*.entity.ts.
|
||||
const auditEntities = [AuditLog, AuditLogCommand];
|
||||
|
||||
const iamEntities = [
|
||||
UnitSetting,
|
||||
@@ -118,30 +123,95 @@ const iamMigrationsGlob = join(
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
/**
|
||||
* Migration history is split per owner instead of sharing one `public.migrations`
|
||||
* table:
|
||||
*
|
||||
* - IAM migrations ship with `@tria-plc/iamapi-common`, target the `iam` schema
|
||||
* and are recorded in `iam.typeorm_migrations` — the same table the package's
|
||||
* own CLI (`pnpm iam:migration:run|show|revert`) uses, so both paths agree on
|
||||
* what has been applied.
|
||||
* - Freight migrations are recorded in `freight.migrations`.
|
||||
*
|
||||
* `public.migrations` is the pre-split table; `src/scripts/migrate.ts` adopts its
|
||||
* rows into the two tables above on first run and then leaves it untouched.
|
||||
*/
|
||||
export const IAM_MIGRATIONS = {
|
||||
schema: "iam",
|
||||
table: "typeorm_migrations",
|
||||
} as const;
|
||||
|
||||
export const FREIGHT_MIGRATIONS = {
|
||||
schema: "freight",
|
||||
table: "migrations",
|
||||
} as const;
|
||||
|
||||
export const LEGACY_MIGRATIONS = {
|
||||
schema: "public",
|
||||
table: "migrations",
|
||||
} as const;
|
||||
|
||||
function buildConnectionOptions() {
|
||||
return {
|
||||
type: "postgres",
|
||||
type: "postgres" as const,
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||
username: process.env.DB_USER ?? "postgres",
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
schema: "public",
|
||||
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
|
||||
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
|
||||
// proxies fronting the remote edr_dev DB) reject with
|
||||
// `08P01 unsupported startup parameter in options: search_path`.
|
||||
// The search_path is instead applied per-connection via a pool `connect`
|
||||
// handler in app.module.ts (see setPoolSearchPath).
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...iamEntities],
|
||||
migrations: [
|
||||
iamMigrationsGlob,
|
||||
freightMigrationsGlob,
|
||||
],
|
||||
migrationsTransactionMode: "each",
|
||||
// handler (app.module.ts `setPoolSearchPath`, migrate.ts `applySearchPath`).
|
||||
synchronize: false,
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
process.env.TYPEORM_LOGGING === "true"
|
||||
? true
|
||||
: (["error", "warn"] as DataSourceOptions["logging"]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime options for the API (and the seed scripts using `AppDataSource`).
|
||||
* Carries no migrations: migrations run only through `src/scripts/migrate.ts`,
|
||||
* which uses the two dedicated DataSources below.
|
||||
*/
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: "public",
|
||||
entities: [
|
||||
__dirname + "/../**/*.entity.{ts,js}",
|
||||
...iamEntities,
|
||||
...auditEntities,
|
||||
],
|
||||
migrations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** IAM migrations only, recorded in `iam.typeorm_migrations`. */
|
||||
export function buildIamMigrationDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: IAM_MIGRATIONS.schema,
|
||||
entities: [],
|
||||
migrations: [iamMigrationsGlob],
|
||||
migrationsTableName: IAM_MIGRATIONS.table,
|
||||
migrationsTransactionMode: "each",
|
||||
};
|
||||
}
|
||||
|
||||
/** Freight migrations only, recorded in `freight.migrations`. */
|
||||
export function buildFreightMigrationDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: FREIGHT_MIGRATIONS.schema,
|
||||
entities: [],
|
||||
migrations: [freightMigrationsGlob],
|
||||
migrationsTableName: FREIGHT_MIGRATIONS.table,
|
||||
migrationsTransactionMode: "each",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ export const APPLICATION_SCHEMAS = [
|
||||
|
||||
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
|
||||
|
||||
/**
|
||||
* Extensions the migrations call into but never create themselves — both the IAM
|
||||
* package's migrations and the freight baseline default columns to
|
||||
* `uuid_generate_v4()` / `gen_random_uuid()`.
|
||||
*/
|
||||
export const APPLICATION_EXTENSIONS = ["uuid-ossp", "pgcrypto"] as const;
|
||||
|
||||
/**
|
||||
* TypeORM creates the migrations table before any migration runs. If `public` was
|
||||
* dropped, current_schema() is null and CREATE TABLE migrations fails.
|
||||
@@ -42,6 +49,20 @@ export async function ensurePostgresSchemas(
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: creating an extension needs elevated rights the app user may not
|
||||
// have. On an established database they are already installed and this is a
|
||||
// no-op, so a failure here is only fatal for a brand-new database — where the
|
||||
// first migration will fail loudly on the missing function anyway.
|
||||
for (const extension of APPLICATION_EXTENSIONS) {
|
||||
try {
|
||||
await bootstrap.query(`CREATE EXTENSION IF NOT EXISTS "${extension}"`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`could not ensure extension "${extension}": ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await bootstrap.query(
|
||||
`SET search_path TO ${APPLICATION_SEARCH_PATH}`,
|
||||
);
|
||||
|
||||
@@ -3,7 +3,10 @@ import Handlebars from 'handlebars';
|
||||
/** One numbered clause of a dynamic article, with optional nested bullets. */
|
||||
export interface RenderedClause {
|
||||
text: string;
|
||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||
/**
|
||||
* Computed outline marker for this clause at its own level: "3" at depth 1,
|
||||
* "b" at depth 2, "iv" at depth 3, cycling back to arabic at depth 4.
|
||||
*/
|
||||
number: string;
|
||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||
depth: number;
|
||||
@@ -35,6 +38,51 @@ const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||
/** Deepest supported sub-clause level (1.1.1.1.1.1). */
|
||||
const MAX_CLAUSE_DEPTH = 6;
|
||||
|
||||
/** 1 → "a", 2 → "b", … 27 → "aa". */
|
||||
function toAlpha(n: number): string {
|
||||
let out = '';
|
||||
let value = n;
|
||||
while (value > 0) {
|
||||
const rem = (value - 1) % 26;
|
||||
out = String.fromCharCode(97 + rem) + out;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
return out || 'a';
|
||||
}
|
||||
|
||||
const ROMAN: Array<[number, string]> = [
|
||||
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'],
|
||||
[100, 'c'], [90, 'xc'], [50, 'l'], [40, 'xl'],
|
||||
[10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||
];
|
||||
|
||||
/** 1 → "i", 4 → "iv", 9 → "ix". */
|
||||
function toRoman(n: number): string {
|
||||
let value = n;
|
||||
let out = '';
|
||||
for (const [amount, numeral] of ROMAN) {
|
||||
while (value >= amount) {
|
||||
out += numeral;
|
||||
value -= amount;
|
||||
}
|
||||
}
|
||||
return out || 'i';
|
||||
}
|
||||
|
||||
/**
|
||||
* Word-processor outline markers, cycling by depth the way Quill's own list
|
||||
* rendering does: 1. → a. → i. → 1. … Depth 1 keeps plain arabic numerals so
|
||||
* top-level clauses read as "1.", "2." in the contract; the marker is the
|
||||
* clause's own counter at its level, NOT a dotted path — "a" under clause 2 is
|
||||
* "a", not "2.a".
|
||||
*/
|
||||
export function clauseMarker(counter: number, depth: number): string {
|
||||
const style = (depth - 1) % 3;
|
||||
if (style === 1) return toAlpha(counter);
|
||||
if (style === 2) return toRoman(counter);
|
||||
return String(counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a template article body into clauses. Format: one clause per line.
|
||||
* A leading outline number ("2. ", "2.1 ", "2.1.3 ") nests the line as a
|
||||
@@ -82,7 +130,7 @@ export function parseArticleBody(body: string): Pick<RenderedArticle, 'paragraph
|
||||
|
||||
clauses.push({
|
||||
text: match ? line.slice(match[0].length).trim() : line,
|
||||
number: counters.slice(0, depth).join('.'),
|
||||
number: clauseMarker(counters[depth - 1], depth),
|
||||
depth,
|
||||
bullets: [],
|
||||
});
|
||||
|
||||
@@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
@@ -165,6 +166,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
contractStartDate: this.formatDate(contract.contractValidFrom),
|
||||
contractEndDate: this.formatDate(contract.contractValidUntil),
|
||||
client: {
|
||||
companyName: contract.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(contract.company?.address),
|
||||
@@ -280,7 +283,8 @@ export class ContractDocumentViewModelBuilder {
|
||||
|
||||
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
|
||||
const firstRoute = this.firstRoute(contract);
|
||||
const cargoScope = (contract.cargoScope ?? [])[0];
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const cargoScope = scope[0];
|
||||
const cargoName =
|
||||
cargoScope?.cargoType?.cargoTypeName ||
|
||||
cargoScope?.cargoFreeText ||
|
||||
@@ -288,6 +292,28 @@ export class ContractDocumentViewModelBuilder {
|
||||
? `${cargoScope.containerSize} container`
|
||||
: 'Container cargo');
|
||||
|
||||
// A contract's scope can list several cargo lines (e.g. coffee in 20ft and
|
||||
// 40ft); name each distinctly rather than collapsing to the first.
|
||||
const containerType = [
|
||||
...new Set(scope.map((s) => s.containerSize ?? '').filter(Boolean)),
|
||||
].join(', ');
|
||||
const cargoTypeName = [
|
||||
...new Set(
|
||||
scope
|
||||
.map((s) => s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? '')
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join(', ');
|
||||
const cargoSummary = scope
|
||||
.map((s) => {
|
||||
const name = s.cargoType?.cargoTypeName ?? s.cargoFreeText ?? null;
|
||||
const size = s.containerSize ? `(${s.containerSize})` : null;
|
||||
const cap = s.quantityCap ? `× ${Number(s.quantityCap)}` : null;
|
||||
return [name, size, cap].filter(Boolean).join(' ');
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(firstRoute?.originYard),
|
||||
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
|
||||
@@ -301,6 +327,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
scheduledDate: this.formatDate(null),
|
||||
contractType: this.valueOrDash(contract.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
cargoTypeName: this.valueOrDash(cargoTypeName),
|
||||
containerType: this.valueOrDash(containerType),
|
||||
cargoSummary: this.valueOrDash(cargoSummary),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
// A hazardous contract names the declared class + UN number on the
|
||||
|
||||
@@ -27,18 +27,32 @@ describe('parseArticleBody', () => {
|
||||
expect(parsed.clauses).toEqual([]);
|
||||
});
|
||||
|
||||
it('nests numbered sub-clauses by their outline token and renumbers sequentially', () => {
|
||||
it('nests sub-clauses by outline token and marks each level 1. → a. → i.', () => {
|
||||
const parsed = parseArticleBody(
|
||||
'1. Scope\n5.1 Rail transport\n1.1.1 Wagon supply\n2. Payment',
|
||||
);
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth, c.text])).toEqual([
|
||||
['1', 1, 'Scope'],
|
||||
['1.1', 2, 'Rail transport'],
|
||||
['1.1.1', 3, 'Wagon supply'],
|
||||
['a', 2, 'Rail transport'],
|
||||
['i', 3, 'Wagon supply'],
|
||||
['2', 1, 'Payment'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('cycles markers back to arabic at depth 4 and counts each level on its own', () => {
|
||||
const parsed = parseArticleBody(
|
||||
'1. One\n1.1 Alpha\n1.2 Beta\n1.2.1 Roman one\n1.2.2 Roman two\n1.2.2.1 Deep',
|
||||
);
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||
['1', 1],
|
||||
['a', 2],
|
||||
['b', 2],
|
||||
['i', 3],
|
||||
['ii', 3],
|
||||
['1', 4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps a sub-clause with no open parent to the next available level', () => {
|
||||
const parsed = parseArticleBody('1.1.1 Orphan sub-clause\nSecond clause.');
|
||||
expect(parsed.clauses.map((c) => [c.number, c.depth])).toEqual([
|
||||
@@ -90,6 +104,8 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' },
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
contractStartDate: '1 January 2026',
|
||||
contractEndDate: '31 December 2026',
|
||||
client: {
|
||||
companyName: 'Abyssinia Trading PLC',
|
||||
companyAddress: 'Bole Sub-city, Addis Ababa',
|
||||
@@ -117,6 +133,9 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
scheduledDate: '—',
|
||||
contractType: 'GENERAL',
|
||||
cargoDescription: 'Steel billets',
|
||||
cargoTypeName: 'Steel billets',
|
||||
containerType: '—',
|
||||
cargoSummary: 'Steel billets × 2,800',
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: '—',
|
||||
hazardousLabel: 'No',
|
||||
@@ -192,6 +211,42 @@ describe('dynamic template rendering (edr-dynamic.hbs)', () => {
|
||||
expect(html).toContain('#1b9e7a');
|
||||
});
|
||||
|
||||
it('shows the contract validity window in the commercial schedule annex', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Valid from');
|
||||
expect(html).toContain('Valid until');
|
||||
expect(html).toContain('1 January 2026');
|
||||
expect(html).toContain('31 December 2026');
|
||||
});
|
||||
|
||||
it('interpolates the start/end date placeholders inside article text', () => {
|
||||
const view = dynamicView();
|
||||
expect(
|
||||
interpolateTemplateText(
|
||||
'In force {{contractStartDate}} to {{contractEndDate}}.',
|
||||
view,
|
||||
),
|
||||
).toBe('In force 1 January 2026 to 31 December 2026.');
|
||||
});
|
||||
|
||||
it('shows cargo type and container type in the commercial schedule annex', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Cargo type');
|
||||
expect(html).toContain('Container type');
|
||||
expect(html).toContain('Cargo scope');
|
||||
expect(html).toContain('Steel billets × 2,800');
|
||||
});
|
||||
|
||||
it('interpolates the cargo/container placeholders inside article text', () => {
|
||||
const view = dynamicView();
|
||||
const body =
|
||||
'Cargo: {{schedule.cargoTypeName}} in {{schedule.containerType}} ' +
|
||||
'({{schedule.freightType}}). Scope: {{schedule.cargoSummary}}.';
|
||||
expect(interpolateTemplateText(body, view)).toBe(
|
||||
'Cargo: Steel billets in — (BULK). Scope: Steel billets × 2,800.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the live rate schedule lane under the pricing article', () => {
|
||||
const html = renderer.render(dynamicView());
|
||||
expect(html).toContain('Rate Schedule');
|
||||
|
||||
@@ -43,6 +43,7 @@ const UNIT_LABELS: Record<string, string> = {
|
||||
PER_TON: 'per ton',
|
||||
PER_CONTAINER: 'per container',
|
||||
PER_KM: 'per km',
|
||||
PER_TON_KM: 'per ton per km',
|
||||
PER_INVOICE: 'per invoice',
|
||||
FLAT: 'flat',
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ describe('ContractRendererService', () => {
|
||||
template,
|
||||
contractDate: '1 January 2026',
|
||||
contractYear: 2026,
|
||||
contractStartDate: '1 January 2026',
|
||||
contractEndDate: '31 December 2026',
|
||||
client: {
|
||||
companyName: 'Test Co',
|
||||
companyAddress: 'Addis Ababa',
|
||||
@@ -43,6 +45,9 @@ describe('ContractRendererService', () => {
|
||||
scheduledDate: '1 January 2026',
|
||||
contractType: 'NEW',
|
||||
cargoDescription: 'Container cargo',
|
||||
cargoTypeName: 'Coffee',
|
||||
containerType: '40ft',
|
||||
cargoSummary: 'Coffee (40ft) × 12',
|
||||
totalWeightVgm: '24 tons',
|
||||
equipmentReturn: 'RETURN',
|
||||
hazardousLabel: 'No',
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface ContractViewModel {
|
||||
template: ContractTemplateMeta;
|
||||
contractDate: string;
|
||||
contractYear: number;
|
||||
/**
|
||||
* The contract's validity window (`contract_valid_from` / `_until`). Distinct
|
||||
* from `contractDate`, which is the day the document is generated — these are
|
||||
* the dates the contract is actually in force between. "—" when unset.
|
||||
*/
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
client: {
|
||||
companyName: string;
|
||||
companyAddress: string;
|
||||
@@ -68,6 +75,16 @@ export interface ContractViewModel {
|
||||
scheduledDate: string;
|
||||
contractType: string;
|
||||
cargoDescription: string;
|
||||
/**
|
||||
* The named cargo type on its own (e.g. "Coffee"), separate from
|
||||
* `cargoDescription` which folds in free text and a container fallback.
|
||||
* Lets a clause name the commodity without the surrounding prose.
|
||||
*/
|
||||
cargoTypeName: string;
|
||||
/** Container size alone, e.g. "20ft" / "40ft"; "—" for bulk. */
|
||||
containerType: string;
|
||||
/** Every cargo line on the contract, e.g. "Coffee (40ft) × 12". */
|
||||
cargoSummary: string;
|
||||
totalWeightVgm: string;
|
||||
equipmentReturn: string;
|
||||
hazardousLabel: string;
|
||||
@@ -133,6 +150,8 @@ export class ContractViewModelBuilder {
|
||||
year: 'numeric',
|
||||
}),
|
||||
contractYear: new Date().getFullYear(),
|
||||
contractStartDate: this.formatDate(booking.contractValidFrom),
|
||||
contractEndDate: this.formatDate(booking.contractValidUntil),
|
||||
client: {
|
||||
companyName: booking.company?.name ?? 'Client',
|
||||
companyAddress: this.valueOrDash(booking.company?.address),
|
||||
@@ -195,6 +214,21 @@ export class ContractViewModelBuilder {
|
||||
'Bulk commodity'
|
||||
: booking.cargoType?.cargoTypeName || 'Container cargo';
|
||||
const totalWeight = Number(booking.cargoTotalWeightVgm || 0);
|
||||
// A booking may carry both sizes; name each one once, in the order booked.
|
||||
const containerType = [
|
||||
...new Set(
|
||||
(booking.bookingContainers ?? [])
|
||||
.map(
|
||||
(line) =>
|
||||
line.containerType?.label ??
|
||||
(line.containerType?.sizeFt
|
||||
? `${line.containerType.sizeFt}ft`
|
||||
: line.containerSize) ??
|
||||
'',
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join(', ');
|
||||
|
||||
return {
|
||||
originLabel: this.yardLabel(booking.originYard),
|
||||
@@ -207,6 +241,13 @@ export class ContractViewModelBuilder {
|
||||
scheduledDate: this.formatDate(booking.scheduledDate),
|
||||
contractType: this.valueOrDash(booking.contractType),
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
cargoTypeName: this.valueOrDash(booking.cargoType?.cargoTypeName),
|
||||
containerType: this.valueOrDash(containerType),
|
||||
cargoSummary: this.valueOrDash(
|
||||
[cargoName, containerType ? `(${containerType})` : null]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
),
|
||||
totalWeightVgm:
|
||||
totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—',
|
||||
equipmentReturn: this.valueOrDash(booking.equipmentReturn),
|
||||
|
||||
@@ -125,12 +125,30 @@
|
||||
<th>Hazardous cargo</th>
|
||||
<td>{{schedule.hazardousLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo type</th>
|
||||
<td>{{schedule.cargoTypeName}}</td>
|
||||
<th>Container type</th>
|
||||
<td>{{schedule.containerType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cargo scope</th>
|
||||
<td>{{schedule.cargoSummary}}</td>
|
||||
<th>Freight type</th>
|
||||
<td>{{schedule.freightType}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Equipment return</th>
|
||||
<td>{{schedule.equipmentReturn}}</td>
|
||||
<th>Payment currency</th>
|
||||
<td>{{paymentArticle}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Valid from</th>
|
||||
<td>{{contractStartDate}}</td>
|
||||
<th>Valid until</th>
|
||||
<td>{{contractEndDate}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Last-Mile Delivery Contract — {{bookingReference}}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; margin: 0; font-size: 12px; line-height: 1.5; }
|
||||
main { padding: 32px 40px; }
|
||||
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
|
||||
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
|
||||
.kicker { margin: 0; font-weight: 700; }
|
||||
.muted { margin: 0; color: #666; }
|
||||
h1 { font-size: 20px; margin: 24px 0 4px; }
|
||||
h2 { font-size: 14px; margin: 26px 0 8px; color: #1a5632; border-bottom: 1px solid #d8d8d8; padding-bottom: 4px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||
th, td { border: 1px solid #d8d8d8; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #f2f6f3; width: 32%; font-weight: 600; }
|
||||
.rates th { width: auto; }
|
||||
.rates .amount { text-align: right; white-space: nowrap; }
|
||||
.rates tfoot td { font-weight: 700; background: #f2f6f3; }
|
||||
.terms p { margin: 6px 0; }
|
||||
.sig-grid { display: flex; gap: 24px; margin-top: 18px; }
|
||||
.sig-card { flex: 1; border: 1px solid #d8d8d8; border-radius: 6px; padding: 14px; min-height: 130px; }
|
||||
.sig-card h3 { margin: 0 0 8px; font-size: 12px; color: #1a5632; }
|
||||
.sig-card img { max-height: 60px; max-width: 100%; }
|
||||
.sig-line { border-top: 1px solid #999; margin-top: 40px; padding-top: 4px; color: #666; }
|
||||
.consent { margin-top: 6px; font-style: italic; color: #444; }
|
||||
.pending { color: #999; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand-row">
|
||||
<div class="logo-mark">EDR</div>
|
||||
<div>
|
||||
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<p class="muted">Last-Mile Delivery Contract</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Last-Mile Delivery Contract</h1>
|
||||
<p class="muted">Booking {{bookingReference}} — {{companyName}}</p>
|
||||
|
||||
<h2>Shipment Details</h2>
|
||||
<table>
|
||||
<tr><th>Client</th><td>{{companyName}}</td></tr>
|
||||
<tr><th>Booking Reference</th><td>{{bookingReference}}</td></tr>
|
||||
{{#if containerCount}}
|
||||
<tr><th>Number of Containers</th><td>{{containerCount}}</td></tr>
|
||||
<tr><th>Containers</th><td>{{containerList}}</td></tr>
|
||||
{{/if}}
|
||||
{{#if cargoDescription}}
|
||||
<tr><th>Cargo Description</th><td>{{cargoDescription}}</td></tr>
|
||||
{{/if}}
|
||||
{{#if deliveryAddress}}
|
||||
<tr><th>Delivery Address</th><td>{{deliveryAddress}}</td></tr>
|
||||
{{/if}}
|
||||
{{#if trainDepartureDate}}
|
||||
<tr><th>Train Departure from Djibouti</th><td>{{trainDepartureDate}}</td></tr>
|
||||
{{/if}}
|
||||
<tr><th>Last-Mile Delivery Date</th><td>{{deliveryDate}}</td></tr>
|
||||
<tr><th>Request Date</th><td>{{requestDate}}</td></tr>
|
||||
<tr><th>Approval Date</th><td>{{approvalDate}}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Rates</h2>
|
||||
<table class="rates">
|
||||
<thead>
|
||||
<tr><th>Description</th><th class="amount">Amount{{#if currency}} ({{currency}}){{/if}}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each rateLines}}
|
||||
<tr><td>{{description}}</td><td class="amount">{{amount}}</td></tr>
|
||||
{{/each}}
|
||||
{{#if estimatedKm}}
|
||||
<tr><td class="muted">Estimated distance</td><td class="amount">{{estimatedKm}} km</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td>Advance payable on signing</td><td class="amount">{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h2>Terms</h2>
|
||||
<div class="terms">
|
||||
<p>1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.</p>
|
||||
<p>2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.</p>
|
||||
<p>3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.</p>
|
||||
<p>4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.</p>
|
||||
</div>
|
||||
|
||||
<h2>Signatures</h2>
|
||||
<div class="sig-grid">
|
||||
<div class="sig-card">
|
||||
<h3>Client</h3>
|
||||
{{#if signature}}
|
||||
<img src="{{signature.imageUrl}}" alt="Customer signature" />
|
||||
<div class="sig-line">{{signature.signerDisplayName}} — signed {{signature.signedAt}}</div>
|
||||
{{#if signature.consentText}}<div class="consent">"{{signature.consentText}}"</div>{{/if}}
|
||||
{{else}}
|
||||
<p class="pending">Awaiting customer signature.</p>
|
||||
<div class="sig-line">Name, signature & date</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="sig-card">
|
||||
<h3>Service Provider</h3>
|
||||
<p>Ethio-Djibouti Standard Gauge Railway Share Company</p>
|
||||
<div class="sig-line">Authorized representative</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ResponseTransformInterceptor,
|
||||
createValidationPipe,
|
||||
} from "@edr/api-common";
|
||||
import { getAuditLoggerConfig } from "@tria-plc/auditlog";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
@@ -19,7 +20,7 @@ import { AppModule } from "./app.module";
|
||||
* ~13.4MB on the wire. Express defaults to 100kb, which rejected any real stamp
|
||||
* image with a 413 "request entity too large".
|
||||
*/
|
||||
const JSON_BODY_LIMIT = '20mb';
|
||||
const JSON_BODY_LIMIT = "20mb";
|
||||
|
||||
/**
|
||||
* Static /etc/hosts-style overrides from `DNS_HOST_OVERRIDES`, formatted as
|
||||
@@ -44,7 +45,9 @@ function applyDnsHostOverrides(): void {
|
||||
}
|
||||
if (overrides.size === 0) return;
|
||||
|
||||
const dns = createRequire(__filename)("node:dns") as typeof import("node:dns");
|
||||
const dns = createRequire(__filename)(
|
||||
"node:dns",
|
||||
) as typeof import("node:dns");
|
||||
const originalLookup = dns.lookup.bind(dns);
|
||||
// `dns.lookup` is overloaded (options optional, all/family variants); the
|
||||
// cast keeps that surface intact while we intercept only mapped hostnames.
|
||||
@@ -63,7 +66,9 @@ function applyDnsHostOverrides(): void {
|
||||
) => void;
|
||||
const family = ip.includes(":") ? 6 : 4;
|
||||
const wantsAll =
|
||||
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
|
||||
typeof options === "object" &&
|
||||
options !== null &&
|
||||
(options as { all?: boolean }).all;
|
||||
|
||||
process.nextTick(() =>
|
||||
wantsAll ? done(null, [{ address: ip, family }]) : done(null, ip, family),
|
||||
@@ -77,7 +82,15 @@ function applyDnsHostOverrides(): void {
|
||||
|
||||
applyDnsHostOverrides();
|
||||
|
||||
async function bootstrap() {
|
||||
/**
|
||||
* Build the app with every global the production process applies, but do NOT
|
||||
* listen. Exported so a test harness can boot the REAL app in its own process
|
||||
* (integration/src/app.ts) and get the same prefix, pipe, filter, interceptor
|
||||
* and body-parser configuration — replaying this list by hand is how an e2e
|
||||
* harness silently drifts from production (routes 404 without the "api"
|
||||
* prefix, responses lose the transform envelope).
|
||||
*/
|
||||
export async function createFreightApp(): Promise<NestExpressApplication> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Nest's own body-parser API, NOT `app.use(json(...))` from express: express
|
||||
@@ -86,14 +99,14 @@ async function bootstrap() {
|
||||
// pnpm's hoisted dev store and died as MODULE_NOT_FOUND in the production
|
||||
// image, where `pnpm deploy --prod` installs declared dependencies only.
|
||||
// This also RECONFIGURES the default parsers rather than racing them.
|
||||
app.useBodyParser('json', { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: JSON_BODY_LIMIT, extended: true });
|
||||
app.useBodyParser("json", { limit: JSON_BODY_LIMIT });
|
||||
app.useBodyParser("urlencoded", { limit: JSON_BODY_LIMIT, extended: true });
|
||||
|
||||
// Dev CORS: reflect any localhost origin and allow credentials so the
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
@@ -130,8 +143,11 @@ async function bootstrap() {
|
||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||
});
|
||||
|
||||
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
||||
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
||||
// /fayda/callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack
|
||||
// endpoint. Exact path, not "fayda" — exclusion is an exact route match, so
|
||||
// "fayda" would leave /fayda/callback prefixed (404 at the registered
|
||||
// redirect_uri) while still reading as if it covered the whole subtree.
|
||||
app.setGlobalPrefix("api", { exclude: ["fayda/callback"] });
|
||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||
// coercion turns any non-empty multipart/form-data string (including the
|
||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||
@@ -145,6 +161,13 @@ async function bootstrap() {
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||
|
||||
// Audit listener: consumes the RMQ events MezgebModule's client interceptor
|
||||
// (app.module.ts) emits and persists them via the AuditLogController /
|
||||
// AuditLogCommandController @EventPattern handlers. Same queue config the
|
||||
// client side uses, reused from the package so the two never drift apart.
|
||||
app.connectMicroservice(getAuditLoggerConfig());
|
||||
await app.startAllMicroservices();
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR Freight API")
|
||||
.setDescription("API for the EDR Freight Management application")
|
||||
@@ -154,13 +177,21 @@ async function bootstrap() {
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await createFreightApp();
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
// await app.listen(port, "0.0.0.0");
|
||||
await app.listen(
|
||||
|
||||
port)
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
// Only self-start when this file IS the entrypoint. The Dockerfile's
|
||||
// `CMD ["node", "dist/main.js"]` still boots; importers get `createFreightApp`
|
||||
// without the process binding a port behind their back.
|
||||
if (require.main === module) {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from "typeorm";
|
||||
|
||||
export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInterface {
|
||||
name = "AddServiceTypesAndCargoTypes1748427600000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create service_types table
|
||||
if (!(await queryRunner.hasTable("freight.service_types"))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "service_types",
|
||||
schema: "freight",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "uuid_generate_v4()",
|
||||
},
|
||||
{
|
||||
name: "service_name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "can_be_booked_alone",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_first_mile",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_last_mile",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "includes_customs",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "priority_bonus_points",
|
||||
type: "int",
|
||||
default: 0,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "is_active",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
type: "timestamptz",
|
||||
default: "now()",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
type: "timestamptz",
|
||||
default: "now()",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "deleted_at",
|
||||
type: "timestamptz",
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Create indexes for service_types
|
||||
const table = await queryRunner.getTable("freight.service_types");
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) {
|
||||
await queryRunner.createIndex(
|
||||
"freight.service_types",
|
||||
new TableIndex({
|
||||
name: "IDX_SERVICE_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Create cargo_types table
|
||||
if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: "cargo_types",
|
||||
schema: "freight",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "uuid_generate_v4()",
|
||||
},
|
||||
{
|
||||
name: "cargo_type_name",
|
||||
type: "varchar",
|
||||
length: "255",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "parent_group_id",
|
||||
type: "uuid",
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: "show_free_text_box",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "requires_director_approval",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "is_active",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "display_order",
|
||||
type: "int",
|
||||
default: 1,
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
type: "timestamptz",
|
||||
default: "now()",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
type: "timestamptz",
|
||||
default: "now()",
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: "deleted_at",
|
||||
type: "timestamptz",
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Create indexes for cargo_types
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_IS_ACTIVE",
|
||||
columnNames: ["is_active"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_DISPLAY_ORDER",
|
||||
columnNames: ["display_order"],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.cargo_types",
|
||||
new TableIndex({
|
||||
name: "IDX_CARGO_TYPES_PARENT_GROUP_ID",
|
||||
columnNames: ["parent_group_id"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Create self-referencing foreign key for cargo_types
|
||||
await queryRunner.createForeignKey(
|
||||
"freight.cargo_types",
|
||||
new TableForeignKey({
|
||||
name: "FK_CARGO_TYPES_PARENT_GROUP",
|
||||
columnNames: ["parent_group_id"],
|
||||
referencedSchema: "freight",
|
||||
referencedTableName: "cargo_types",
|
||||
referencedColumnNames: ["id"],
|
||||
onDelete: "SET NULL",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop foreign key first
|
||||
await queryRunner.dropForeignKey("freight.cargo_types", "FK_CARGO_TYPES_PARENT_GROUP");
|
||||
|
||||
// Drop cargo_types table
|
||||
await queryRunner.dropTable("freight.cargo_types", true);
|
||||
|
||||
// Drop service_types table
|
||||
await queryRunner.dropTable("freight.service_types", true);
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
TableColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export class AddRuleEngineTablesAndCodes1748514000000 implements MigrationInterface {
|
||||
name = 'AddRuleEngineTablesAndCodes1748514000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. Add `code` column to existing tables ───────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasColumn('freight.service_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.service_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = upper(replace(service_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.service_types SET code = 'SERVICE_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.service_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
const serviceTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_service_types_code' LIMIT 1`,
|
||||
);
|
||||
if (serviceTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.service_types',
|
||||
new TableIndex({ name: 'IDX_service_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasColumn('freight.cargo_types', 'code'))) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.cargo_types',
|
||||
new TableColumn({
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = upper(replace(cargo_type_name, ' ', '_')) WHERE code IS NULL OR code = ''`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.cargo_types SET code = 'CARGO_' || substring(id::text, 1, 8) WHERE code IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.cargo_types ALTER COLUMN code SET NOT NULL`,
|
||||
);
|
||||
const cargoTypesCodeIdx = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND indexname = 'idx_cargo_types_code' LIMIT 1`,
|
||||
);
|
||||
if (cargoTypesCodeIdx.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.cargo_types',
|
||||
new TableIndex({ name: 'IDX_cargo_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. surcharge_types ────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.surcharge_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharge_types',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '50', isNullable: false },
|
||||
{ name: 'name', type: 'varchar', length: '100', isNullable: false },
|
||||
{ name: 'description', type: 'text', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharge_types',
|
||||
new TableIndex({ name: 'IDX_surcharge_types_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharge_types',
|
||||
new TableIndex({ name: 'IDX_surcharge_types_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 3. surcharges ─────────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.surcharges'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'surcharges',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'surcharge_type_id', type: 'uuid', isNullable: false },
|
||||
{ name: 'fee_name', type: 'varchar', length: '255', isNullable: false },
|
||||
{ name: 'trigger_description', type: 'text', isNullable: true },
|
||||
{
|
||||
name: 'calculation_method',
|
||||
type: 'enum',
|
||||
enum: ['PER_TON', 'FLAT_FEE', 'PERCENTAGE'],
|
||||
default: `'PER_TON'`,
|
||||
},
|
||||
{ name: 'rate', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{ name: 'currency', type: 'char', length: '3', default: `'USD'` },
|
||||
{ name: 'apply_to_rail', type: 'boolean', default: false },
|
||||
{ name: 'apply_to_first_mile', type: 'boolean', default: false },
|
||||
{ name: 'apply_to_last_mile', type: 'boolean', default: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.surcharges',
|
||||
new TableForeignKey({
|
||||
name: 'FK_surcharges_surcharge_type',
|
||||
columnNames: ['surcharge_type_id'],
|
||||
referencedTableName: 'freight.surcharge_types',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'RESTRICT',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharges',
|
||||
new TableIndex({ name: 'IDX_surcharges_surcharge_type_id', columnNames: ['surcharge_type_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.surcharges',
|
||||
new TableIndex({ name: 'IDX_surcharges_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 4. container_types ────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.container_types'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'container_types',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'size_code', type: 'varchar', length: '20', isNullable: false },
|
||||
{ name: 'description', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'containers_per_wagon', type: 'int', isNullable: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.container_types',
|
||||
new TableIndex({ name: 'IDX_container_types_size_code', columnNames: ['size_code'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.container_types',
|
||||
new TableIndex({ name: 'IDX_container_types_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 5. weight_limit_rules ─────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.weight_limit_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'weight_limit_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_type_id', type: 'uuid', isNullable: false },
|
||||
{
|
||||
name: 'trade_direction',
|
||||
type: 'enum',
|
||||
enum: ['IMPORT', 'EXPORT', 'BOTH'],
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'max_weight_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{ name: 'warning_threshold_tons', type: 'numeric', precision: 10, scale: 2, isNullable: false },
|
||||
{
|
||||
name: 'exceeded_action',
|
||||
type: 'enum',
|
||||
enum: ['WARNING_ONLY', 'HARD_BLOCK'],
|
||||
default: `'WARNING_ONLY'`,
|
||||
},
|
||||
{ name: 'surcharge_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.weight_limit_rules',
|
||||
new TableForeignKey({
|
||||
name: 'FK_weight_limit_rules_container_type',
|
||||
columnNames: ['container_type_id'],
|
||||
referencedTableName: 'freight.container_types',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'RESTRICT',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.weight_limit_rules',
|
||||
new TableForeignKey({
|
||||
name: 'FK_weight_limit_rules_surcharge',
|
||||
columnNames: ['surcharge_id'],
|
||||
referencedTableName: 'freight.surcharges',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_container_type_id', columnNames: ['container_type_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_surcharge_id', columnNames: ['surcharge_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.weight_limit_rules',
|
||||
new TableIndex({ name: 'IDX_weight_limit_rules_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
|
||||
// ── 6. priority_rules ─────────────────────────────────────────────────
|
||||
|
||||
if (!(await queryRunner.hasTable('freight.priority_rules'))) await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'priority_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{
|
||||
name: 'priority_type',
|
||||
type: 'enum',
|
||||
enum: ['USD_PAYER', 'RAIL_AND_FORWARDING', 'GOVERNMENT_ACCOUNT', 'HIGH_VOLUME_SHIPMENT'],
|
||||
isNullable: false,
|
||||
},
|
||||
{ name: 'rule_name', type: 'varchar', length: '255', isNullable: false },
|
||||
{ name: 'description', type: 'text', isNullable: true },
|
||||
{ name: 'activation_condition', type: 'text', isNullable: true },
|
||||
{ name: 'bonus_points', type: 'int', default: 0 },
|
||||
{ name: 'is_active', type: 'boolean', default: false },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'IDX_priority_rules_priority_type', columnNames: ['priority_type'], isUnique: true }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'IDX_priority_rules_is_active', columnNames: ['is_active'] }),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.priority_rules', true);
|
||||
await queryRunner.dropTable('freight.weight_limit_rules', true);
|
||||
await queryRunner.dropTable('freight.container_types', true);
|
||||
await queryRunner.dropTable('freight.surcharges', true);
|
||||
await queryRunner.dropTable('freight.surcharge_types', true);
|
||||
await queryRunner.dropIndex('freight.cargo_types', 'IDX_cargo_types_code');
|
||||
await queryRunner.dropColumn('freight.cargo_types', 'code');
|
||||
await queryRunner.dropIndex('freight.service_types', 'IDX_service_types_code');
|
||||
await queryRunner.dropColumn('freight.service_types', 'code');
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Pre-ITMLS baseline for fresh databases. Older environments created `freight.bookings`
|
||||
* via synchronize or manual SQL; ItmlsFullSchemaRewrite only ALTERs that table.
|
||||
*/
|
||||
export class CreateFreightLegacyBaseline1748550000000 implements MigrationInterface {
|
||||
name = 'CreateFreightLegacyBaseline1748550000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.train_status AS ENUM (
|
||||
'AVAILABLE', 'SCHEDULED', 'IN_SERVICE', 'UNDER_MAINTENANCE', 'OUT_OF_SERVICE'
|
||||
);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.trains (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
capacity_tons NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
||||
status freight.train_status NOT NULL DEFAULT 'AVAILABLE',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.bookings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
reference VARCHAR(64) NOT NULL UNIQUE,
|
||||
customer_id UUID NOT NULL,
|
||||
train_id UUID,
|
||||
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
|
||||
scheduled_date TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
total_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
||||
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
||||
contract_type VARCHAR(20) NOT NULL DEFAULT 'SPOT',
|
||||
previous_contract_id UUID,
|
||||
trade_direction VARCHAR(10) NOT NULL DEFAULT 'IMPORT',
|
||||
equipment_return VARCHAR(20) NOT NULL DEFAULT 'RETURN',
|
||||
first_mile_pickup_address TEXT,
|
||||
last_mile_delivery_address TEXT,
|
||||
cargo_total_weight_vgm NUMERIC(12, 3) NOT NULL DEFAULT 0,
|
||||
is_hazardous BOOLEAN NOT NULL DEFAULT false,
|
||||
payment_currency VARCHAR(5) NOT NULL DEFAULT 'USD',
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
financial_terms TEXT,
|
||||
version_number INT NOT NULL DEFAULT 1,
|
||||
approved_by_staff_id UUID,
|
||||
approved_by_staff_at TIMESTAMPTZ,
|
||||
signed_by_director_id UUID,
|
||||
signed_by_director_at TIMESTAMPTZ,
|
||||
signed_by_ceo_id UUID,
|
||||
signed_by_ceo_at TIMESTAMPTZ,
|
||||
priority_score INT NOT NULL DEFAULT 0,
|
||||
allow_consolidation BOOLEAN NOT NULL DEFAULT false,
|
||||
consolidation_partner_id UUID,
|
||||
origin_station VARCHAR(255),
|
||||
destination_station VARCHAR(255),
|
||||
service_type VARCHAR(100),
|
||||
freight_type VARCHAR(100),
|
||||
freight_subtype VARCHAR(255),
|
||||
containers JSONB,
|
||||
first_mile_enabled BOOLEAN DEFAULT false,
|
||||
last_mile_enabled BOOLEAN DEFAULT false,
|
||||
is_refrigerated BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.bookings CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.trains CASCADE`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.train_status`);
|
||||
}
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableForeignKey,
|
||||
TableIndex,
|
||||
TableUnique,
|
||||
} from 'typeorm';
|
||||
|
||||
export class ItmlsFullSchemaRewrite1748600000000 implements MigrationInterface {
|
||||
name = 'ItmlsFullSchemaRewrite1748600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── container_types ───────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.container_types RENAME COLUMN size_code TO code;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.container_types RENAME COLUMN description TO label;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS size_ft SMALLINT,
|
||||
ADD COLUMN IF NOT EXISTS is_reefer BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS is_open_top BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS display_order INT NOT NULL DEFAULT 1;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS wagons_per_unit NUMERIC(4,2);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = CASE
|
||||
WHEN containers_per_wagon > 0 THEN ROUND(1.0 / containers_per_wagon, 2)
|
||||
ELSE 1.00
|
||||
END
|
||||
WHERE wagons_per_unit IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET size_ft = CASE WHEN code LIKE '40%' OR code LIKE '%40%' THEN 40 ELSE 20 END
|
||||
WHERE size_ft IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ALTER COLUMN wagons_per_unit SET NOT NULL,
|
||||
DROP COLUMN IF EXISTS containers_per_wagon;
|
||||
`);
|
||||
|
||||
// ── weight_limit_rules ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.weight_limit_rules RENAME COLUMN max_weight_tons TO max_vgm_tons;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ALTER COLUMN max_vgm_tons TYPE NUMERIC(8,3);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
ADD COLUMN IF NOT EXISTS effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
ADD COLUMN IF NOT EXISTS effective_to DATE;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.weight_limit_rules
|
||||
DROP COLUMN IF EXISTS warning_threshold_tons,
|
||||
DROP COLUMN IF EXISTS exceeded_action,
|
||||
DROP COLUMN IF EXISTS surcharge_id,
|
||||
DROP COLUMN IF EXISTS is_active;
|
||||
`);
|
||||
|
||||
// ── priority_rules ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
ADD COLUMN IF NOT EXISTS code VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS label VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS score INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS condition_currency VARCHAR(5);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.priority_rules
|
||||
SET code = COALESCE(code, upper(priority_type::text)),
|
||||
label = COALESCE(label, rule_name),
|
||||
score = COALESCE(score, bonus_points)
|
||||
WHERE code IS NULL OR label IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
DROP COLUMN IF EXISTS priority_type,
|
||||
DROP COLUMN IF EXISTS rule_name,
|
||||
DROP COLUMN IF EXISTS bonus_points,
|
||||
DROP COLUMN IF EXISTS activation_condition,
|
||||
DROP COLUMN IF EXISTS description;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.priority_rules
|
||||
ALTER COLUMN code SET NOT NULL,
|
||||
ALTER COLUMN label SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.createIndex(
|
||||
'freight.priority_rules',
|
||||
new TableIndex({ name: 'UQ_priority_rules_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
|
||||
// ── rates (before surcharge_types.rate_id) ────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'rates',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'rate_type', type: 'varchar', length: '50' },
|
||||
{ name: 'container_type_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '10', isNullable: true },
|
||||
{ name: 'currency', type: 'varchar', length: '5' },
|
||||
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
|
||||
{ name: 'rate_unit', type: 'varchar', length: '30' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
|
||||
{ name: 'proposed_by_staff_id', type: 'uuid' },
|
||||
{ name: 'approved_by_ceo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'approved_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'effective_from', type: 'date' },
|
||||
{ name: 'effective_to', type: 'date', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// ── surcharge_types ───────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.surcharge_types RENAME COLUMN name TO label;
|
||||
EXCEPTION WHEN undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.surcharge_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_condition VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS rate_id UUID;
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.surcharge_types DROP COLUMN IF EXISTS description`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharges CASCADE`);
|
||||
|
||||
// ── yards ─────────────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'yards',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '20' },
|
||||
{ name: 'label', type: 'varchar', length: '100' },
|
||||
{ name: 'country', type: 'varchar', length: '50' },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'display_order', type: 'int', default: 1 },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.yards',
|
||||
new TableIndex({ name: 'UQ_yards_code', columnNames: ['code'], isUnique: true }),
|
||||
);
|
||||
|
||||
// ── shipping_lines ────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'shipping_lines',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'code', type: 'varchar', length: '20' },
|
||||
{ name: 'label', type: 'varchar', length: '100' },
|
||||
{ name: 'mapped_to_code', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'show_extra_fee_notice', type: 'boolean', default: false },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// ── approval_rules ────────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'approval_rules',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'requires_director_approval', type: 'boolean' },
|
||||
{ name: 'step_order', type: 'smallint' },
|
||||
{ name: 'required_role', type: 'varchar', length: '30' },
|
||||
{ name: 'action_label', type: 'varchar', length: '50' },
|
||||
{ name: 'blocks_role', type: 'varchar', length: '30', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createUniqueConstraint(
|
||||
'freight.approval_rules',
|
||||
new TableUnique({
|
||||
name: 'UQ_approval_rules_chain_step',
|
||||
columnNames: ['requires_director_approval', 'step_order'],
|
||||
}),
|
||||
);
|
||||
|
||||
// ── bookings ────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS origin_yard_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS destination_yard_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS service_type_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS cargo_free_text VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS shipping_line_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS pnr_code VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS customer_signed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS fully_executed_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.yards (id, code, label, country, is_active, display_order, created_at, updated_at)
|
||||
VALUES
|
||||
(uuid_generate_v4(), 'KALITY', 'Kality Rail Terminal', 'Ethiopia', true, 1, now(), now()),
|
||||
(uuid_generate_v4(), 'MOJO', 'Mojo Dry Port', 'Ethiopia', true, 2, now(), now()),
|
||||
(uuid_generate_v4(), 'DIRE_DAWA', 'Dire Dawa Yard', 'Ethiopia', true, 3, now(), now()),
|
||||
(uuid_generate_v4(), 'DJIB_PORT', 'Djibouti Port Terminal', 'Djibouti', true, 4, now(), now()),
|
||||
(uuid_generate_v4(), 'NAGAD', 'Nagad Terminal, Djibouti', 'Djibouti', true, 5, now(), now()),
|
||||
(uuid_generate_v4(), 'LEGACY_ORIGIN', 'Legacy Origin', 'Ethiopia', true, 99, now(), now()),
|
||||
(uuid_generate_v4(), 'LEGACY_DEST', 'Legacy Destination', 'Ethiopia', true, 100, now(), now())
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
|
||||
`);
|
||||
|
||||
const hasServiceTypeCol = await queryRunner.query(`
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'freight' AND table_name = 'bookings' AND column_name = 'service_type'
|
||||
LIMIT 1;
|
||||
`);
|
||||
|
||||
if (hasServiceTypeCol.length > 0) {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET service_type_id = st.id
|
||||
FROM freight.service_types st
|
||||
WHERE b.service_type_id IS NULL
|
||||
AND (
|
||||
st.code = b.service_type
|
||||
OR upper(replace(st.service_name, ' ', '_')) = upper(b.service_type)
|
||||
OR st.code = upper(replace(b.service_type, ' ', '_'))
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET cargo_type_id = ct.id
|
||||
FROM freight.cargo_types ct
|
||||
WHERE b.cargo_type_id IS NULL
|
||||
AND (
|
||||
ct.code = upper(b.freight_type)
|
||||
OR ct.code = upper(concat(b.freight_type, '_', coalesce(b.freight_subtype, '')))
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET cargo_free_text = b.freight_subtype
|
||||
WHERE b.cargo_free_text IS NULL AND b.freight_subtype IS NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET origin_yard_id = y.id
|
||||
FROM freight.yards y
|
||||
WHERE b.origin_yard_id IS NULL
|
||||
AND (y.label ILIKE b.origin_station OR y.code = upper(replace(b.origin_station, ' ', '_')));
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET destination_yard_id = y.id
|
||||
FROM freight.yards y
|
||||
WHERE b.destination_yard_id IS NULL
|
||||
AND (y.label ILIKE b.destination_station OR y.code = upper(replace(b.destination_station, ' ', '_')));
|
||||
`);
|
||||
}
|
||||
|
||||
const defaultServiceTypeId = await queryRunner.query(
|
||||
`SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1`,
|
||||
);
|
||||
const defaultCargoTypeId = await queryRunner.query(
|
||||
`SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1`,
|
||||
);
|
||||
const legacyOriginId = await queryRunner.query(
|
||||
`SELECT id FROM freight.yards WHERE code = 'LEGACY_ORIGIN' LIMIT 1`,
|
||||
);
|
||||
const legacyDestId = await queryRunner.query(
|
||||
`SELECT id FROM freight.yards WHERE code = 'LEGACY_DEST' LIMIT 1`,
|
||||
);
|
||||
|
||||
if (defaultServiceTypeId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET service_type_id = $1 WHERE service_type_id IS NULL`,
|
||||
[defaultServiceTypeId[0].id],
|
||||
);
|
||||
}
|
||||
if (defaultCargoTypeId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET cargo_type_id = $1 WHERE cargo_type_id IS NULL`,
|
||||
[defaultCargoTypeId[0].id],
|
||||
);
|
||||
}
|
||||
if (legacyOriginId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET origin_yard_id = $1 WHERE origin_yard_id IS NULL`,
|
||||
[legacyOriginId[0].id],
|
||||
);
|
||||
}
|
||||
if (legacyDestId[0]?.id) {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.bookings SET destination_yard_id = $1 WHERE destination_yard_id IS NULL`,
|
||||
[legacyDestId[0].id],
|
||||
);
|
||||
}
|
||||
|
||||
const nullBookings = await queryRunner.query(
|
||||
`SELECT COUNT(*)::int AS cnt FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`,
|
||||
);
|
||||
if (nullBookings[0]?.cnt > 0) {
|
||||
await queryRunner.query(`DELETE FROM freight.bookings WHERE service_type_id IS NULL OR cargo_type_id IS NULL OR origin_yard_id IS NULL OR destination_yard_id IS NULL`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN service_type_id SET NOT NULL,
|
||||
ALTER COLUMN cargo_type_id SET NOT NULL,
|
||||
ALTER COLUMN origin_yard_id SET NOT NULL,
|
||||
ALTER COLUMN destination_yard_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS origin_station,
|
||||
DROP COLUMN IF EXISTS destination_station,
|
||||
DROP COLUMN IF EXISTS service_type,
|
||||
DROP COLUMN IF EXISTS freight_type,
|
||||
DROP COLUMN IF EXISTS freight_subtype,
|
||||
DROP COLUMN IF EXISTS containers,
|
||||
DROP COLUMN IF EXISTS first_mile_enabled,
|
||||
DROP COLUMN IF EXISTS last_mile_enabled,
|
||||
DROP COLUMN IF EXISTS is_refrigerated;
|
||||
`);
|
||||
|
||||
// ── booking_container ─────────────────────────────────────────────────
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_container',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_type_id', type: 'uuid' },
|
||||
{ name: 'quantity', type: 'smallint' },
|
||||
{ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 },
|
||||
{ name: 'total_vgm_tons', type: 'numeric', precision: 12, scale: 3 },
|
||||
{ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2 },
|
||||
{ name: 'weight_limit_rule_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'is_overweight', type: 'boolean', default: false },
|
||||
{ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_rate_snapshot',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'rate_id', type: 'uuid' },
|
||||
{ name: 'rate_type', type: 'varchar', length: '50' },
|
||||
{ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 },
|
||||
{ name: 'rate_unit', type: 'varchar', length: '30' },
|
||||
{ name: 'currency', type: 'varchar', length: '5' },
|
||||
{ name: 'snapshotted_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_cargo_modifier',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'surcharge_type_id', type: 'uuid' },
|
||||
{ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, isNullable: true },
|
||||
{ name: 'calculated_amount', type: 'numeric', precision: 14, scale: 2 },
|
||||
{ name: 'rate_snapshot_id', type: 'uuid' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'booking_approval_step',
|
||||
schema: 'freight',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'approval_rule_id', type: 'uuid' },
|
||||
{ name: 'step_order', type: 'smallint' },
|
||||
{ name: 'required_role', type: 'varchar', length: '30' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
|
||||
{ name: 'actioned_by_staff_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'actioned_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'remarks', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Foreign keys
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.surcharge_types',
|
||||
new TableForeignKey({
|
||||
name: 'FK_surcharge_types_rate_id',
|
||||
columnNames: ['rate_id'],
|
||||
referencedTableName: 'rates',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.booking_container',
|
||||
new TableForeignKey({
|
||||
columnNames: ['booking_id'],
|
||||
referencedTableName: 'bookings',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.bookings',
|
||||
new TableForeignKey({
|
||||
columnNames: ['origin_yard_id'],
|
||||
referencedTableName: 'yards',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.bookings',
|
||||
new TableForeignKey({
|
||||
columnNames: ['destination_yard_id'],
|
||||
referencedTableName: 'yards',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.booking_approval_step', true);
|
||||
await queryRunner.dropTable('freight.booking_cargo_modifier', true);
|
||||
await queryRunner.dropTable('freight.booking_rate_snapshot', true);
|
||||
await queryRunner.dropTable('freight.booking_container', true);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS origin_station VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS destination_station VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS service_type VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS freight_subtype VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS containers JSONB,
|
||||
ADD COLUMN IF NOT EXISTS first_mile_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS last_mile_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS is_refrigerated BOOLEAN DEFAULT false;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS origin_yard_id,
|
||||
DROP COLUMN IF EXISTS destination_yard_id,
|
||||
DROP COLUMN IF EXISTS service_type_id,
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS cargo_free_text,
|
||||
DROP COLUMN IF EXISTS shipping_line_id,
|
||||
DROP COLUMN IF EXISTS pnr_code,
|
||||
DROP COLUMN IF EXISTS customer_signed_at,
|
||||
DROP COLUMN IF EXISTS fully_executed_at;
|
||||
`);
|
||||
|
||||
await queryRunner.dropTable('freight.approval_rules', true);
|
||||
await queryRunner.dropTable('freight.shipping_lines', true);
|
||||
await queryRunner.dropTable('freight.yards', true);
|
||||
await queryRunner.dropTable('freight.rates', true);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingsConfigForeignKeys1748700000000 implements MigrationInterface {
|
||||
name = 'AddBookingsConfigForeignKeys1748700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Ensure parent config rows exist for backfill
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.service_types (id, code, service_name, can_be_booked_alone, includes_first_mile, includes_last_mile, includes_customs, priority_bonus_points, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'RAIL', 'Rail Transport Only', true, false, false, false, 0, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.service_types LIMIT 1);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.cargo_types (id, code, cargo_type_name, requires_director_approval, show_free_text_box, is_active, display_order, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), 'GENERAL', 'General Cargo', false, false, true, 1, now(), now()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types LIMIT 1);
|
||||
`);
|
||||
|
||||
// Clear orphan shipping_line references (nullable FK)
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET shipping_line_id = NULL
|
||||
WHERE b.shipping_line_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.shipping_lines sl WHERE sl.id = b.shipping_line_id
|
||||
);
|
||||
`);
|
||||
|
||||
// Backfill required FK columns
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET service_type_id = (SELECT id FROM freight.service_types ORDER BY display_order LIMIT 1)
|
||||
WHERE service_type_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.service_types st WHERE st.id = service_type_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET cargo_type_id = (SELECT id FROM freight.cargo_types ORDER BY display_order LIMIT 1)
|
||||
WHERE cargo_type_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.cargo_types ct WHERE ct.id = cargo_type_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_service_type_id"
|
||||
FOREIGN KEY (service_type_id)
|
||||
REFERENCES freight.service_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_shipping_line_id"
|
||||
FOREIGN KEY (shipping_line_id)
|
||||
REFERENCES freight.shipping_lines(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_shipping_line_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_cargo_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_service_type_id";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingsRemainingForeignKeys1748800000000 implements MigrationInterface {
|
||||
name = 'AddBookingsRemainingForeignKeys1748800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const publicCustomersExists = await queryRunner.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'customers'
|
||||
) AS exists
|
||||
`);
|
||||
const hasPublicCustomers = Boolean(publicCustomersExists[0]?.exists);
|
||||
|
||||
// ── freight.bookings: nullable FK cleanup ─────────────────────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET train_id = NULL
|
||||
WHERE b.train_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.id = b.train_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET previous_contract_id = NULL
|
||||
WHERE b.previous_contract_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.previous_contract_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET consolidation_partner_id = NULL
|
||||
WHERE b.consolidation_partner_id IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.bookings pb WHERE pb.id = b.consolidation_partner_id);
|
||||
`);
|
||||
|
||||
// Legacy DBs only: public.customers is created/moved in MoveCustomersToFreightSchema (174890).
|
||||
if (hasPublicCustomers) {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.bookings b
|
||||
WHERE bcm.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
USING freight.bookings b
|
||||
WHERE bas.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
USING freight.bookings b
|
||||
WHERE brs.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
USING freight.bookings b
|
||||
WHERE bc.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.bookings b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES public.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── freight.bookings FKs ────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_train_id"
|
||||
FOREIGN KEY (train_id)
|
||||
REFERENCES freight.trains(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_previous_contract_id"
|
||||
FOREIGN KEY (previous_contract_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_consolidation_partner_id"
|
||||
FOREIGN KEY (consolidation_partner_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_container ─────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_container bc
|
||||
SET weight_limit_rule_id = NULL
|
||||
WHERE bc.weight_limit_rule_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.weight_limit_rules wlr WHERE wlr.id = bc.weight_limit_rule_id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types ct WHERE ct.id = bc.container_type_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD CONSTRAINT "FK_booking_container_container_type_id"
|
||||
FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD CONSTRAINT "FK_booking_container_weight_limit_rule_id"
|
||||
FOREIGN KEY (weight_limit_rule_id)
|
||||
REFERENCES freight.weight_limit_rules(id)
|
||||
ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_rate_snapshot ─────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.booking_rate_snapshot brs
|
||||
WHERE bcm.rate_snapshot_id = brs.id
|
||||
AND (
|
||||
NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id)
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = brs.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.rates r WHERE r.id = brs.rate_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
ADD CONSTRAINT "FK_booking_rate_snapshot_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
ADD CONSTRAINT "FK_booking_rate_snapshot_rate_id"
|
||||
FOREIGN KEY (rate_id)
|
||||
REFERENCES freight.rates(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_approval_step ─────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bas.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.approval_rules ar WHERE ar.id = bas.approval_rule_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD CONSTRAINT "FK_booking_approval_step_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD CONSTRAINT "FK_booking_approval_step_approval_rule_id"
|
||||
FOREIGN KEY (approval_rule_id)
|
||||
REFERENCES freight.approval_rules(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ── freight.booking_cargo_modifier ────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.bookings b WHERE b.id = bcm.booking_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM freight.surcharge_types st WHERE st.id = bcm.surcharge_type_id)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.booking_rate_snapshot brs WHERE brs.id = bcm.rate_snapshot_id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_booking_id"
|
||||
FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
ON DELETE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_surcharge_type_id"
|
||||
FOREIGN KEY (surcharge_type_id)
|
||||
REFERENCES freight.surcharge_types(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_snapshot_id"
|
||||
FOREIGN KEY (rate_snapshot_id)
|
||||
REFERENCES freight.booking_rate_snapshot(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_snapshot_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_approval_rule_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_approval_step_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_rate_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_rate_snapshot
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_rate_snapshot_booking_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_container_weight_limit_rule_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_container_container_type_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_consolidation_partner_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_previous_contract_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_train_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveCustomersToFreightSchema1748900000000 implements MigrationInterface {
|
||||
name = 'MoveCustomersToFreightSchema1748900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
company_name VARCHAR(200) NOT NULL,
|
||||
company_email VARCHAR(150) NOT NULL,
|
||||
company_phone VARCHAR(20) NOT NULL,
|
||||
company_location VARCHAR(100) NOT NULL,
|
||||
company_address TEXT NOT NULL,
|
||||
customer_type VARCHAR(32),
|
||||
status VARCHAR(32),
|
||||
contact_person_name VARCHAR(100) NOT NULL,
|
||||
contact_person_phone VARCHAR(20) NOT NULL,
|
||||
tin_number VARCHAR(10) NOT NULL UNIQUE,
|
||||
vat_number VARCHAR(50),
|
||||
fan_number VARCHAR(16) NOT NULL UNIQUE,
|
||||
general_manager_name VARCHAR(100) NOT NULL,
|
||||
general_manager_email VARCHAR(150) NOT NULL,
|
||||
general_manager_phone VARCHAR(20) NOT NULL,
|
||||
poa_name VARCHAR(100),
|
||||
poa_phone VARCHAR(20),
|
||||
poa_address TEXT,
|
||||
poa_email VARCHAR(150),
|
||||
poa_location VARCHAR(100),
|
||||
notes TEXT,
|
||||
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_freight_customers_email"
|
||||
ON freight.customers (email);
|
||||
`);
|
||||
// await queryRunner.query(`
|
||||
// CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id"
|
||||
// ON freight.customers (user_id);
|
||||
//`);
|
||||
|
||||
// Copy rows from public.customers when that legacy table exists
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
has_public boolean;
|
||||
has_user_id boolean;
|
||||
has_userid boolean;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'customers'
|
||||
) INTO has_public;
|
||||
|
||||
IF NOT has_public THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'user_id'
|
||||
) INTO has_user_id;
|
||||
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'customers' AND column_name = 'userid'
|
||||
) INTO has_userid;
|
||||
|
||||
IF has_user_id THEN
|
||||
INSERT INTO freight.customers (
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
COALESCE(created_at, now()), COALESCE(updated_at, now())
|
||||
FROM public.customers
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
ELSIF has_userid THEN
|
||||
INSERT INTO freight.customers (
|
||||
id, user_id, first_name, last_name, email, phone,
|
||||
company_name, company_email, company_phone, company_location, company_address,
|
||||
contact_person_name, contact_person_phone, tin_number, vat_number, fan_number,
|
||||
general_manager_name, general_manager_email, general_manager_phone,
|
||||
poa_name, poa_phone, poa_address, poa_email, poa_location, notes,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, userid, firstname, lastname, email, phone,
|
||||
companyname, companyemail, companyphone, companylocation, companyaddress,
|
||||
contactpersonname, contactpersonphone, tinnumber, vatnumber, fannumber,
|
||||
generalmanagername, generalmanageremail, generalmanagerphone,
|
||||
poaname, poaphone, poaaddress, poaemail, poalocation, notes,
|
||||
COALESCE("createdAt", now()), COALESCE("updatedAt", now())
|
||||
FROM public.customers
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier bcm
|
||||
USING freight.bookings b
|
||||
WHERE bcm.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_approval_step bas
|
||||
USING freight.bookings b
|
||||
WHERE bas.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_rate_snapshot brs
|
||||
USING freight.bookings b
|
||||
WHERE brs.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_container bc
|
||||
USING freight.bookings b
|
||||
WHERE bc.booking_id = b.id
|
||||
AND NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.bookings b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM freight.customers c WHERE c.id = b.customer_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES freight.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_customer_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_customer_id"
|
||||
FOREIGN KEY (customer_id)
|
||||
REFERENCES public.customers(id)
|
||||
ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.customers CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Align weight_limit_rules.trade_direction with app code: IMPORT, EXPORT, BOTH (not ANY).
|
||||
*/
|
||||
export class NormalizeWeightLimitTradeDirectionBoth1749000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'NormalizeWeightLimitTradeDirectionBoth1749000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'BOTH'
|
||||
WHERE trade_direction::text = 'ANY';
|
||||
|
||||
UPDATE freight.weight_limit_rules
|
||||
SET trade_direction = 'IMPORT'
|
||||
WHERE trade_direction IS NULL;
|
||||
EXCEPTION WHEN undefined_table OR undefined_column THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// No-op: ANY is not a valid enum value in PostgreSQL.
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateFreightFilesTable1749100000000 implements MigrationInterface {
|
||||
name = 'CreateFreightFilesTable1749100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.files (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
resource_id UUID NOT NULL,
|
||||
resource VARCHAR(100) NOT NULL,
|
||||
code VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(500) NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
mime_type VARCHAR(255) 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_freight_files_resource"
|
||||
ON freight.files (resource_id, resource);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_freight_files_resource_code"
|
||||
ON freight.files (resource_id, resource, code);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.files CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
|
||||
name = 'BookingFlowRefactor1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
author_id UUID,
|
||||
note TEXT NOT NULL,
|
||||
type VARCHAR(30) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
|
||||
ON freight.booking_review_note(booking_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
|
||||
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET status = 'SUBMITTED'
|
||||
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
|
||||
UPDATE freight.bookings SET status = 'REJECTED'
|
||||
WHERE status = 'QUOTATION_REJECTED';
|
||||
UPDATE freight.bookings SET status = 'CANCELLED'
|
||||
WHERE status = 'CANCELLED';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS locked_at,
|
||||
DROP COLUMN IF EXISTS contract_summary,
|
||||
DROP COLUMN IF EXISTS marketing_approved_at,
|
||||
DROP COLUMN IF EXISTS marketing_approved_by_id;
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
|
||||
|
||||
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
|
||||
name = 'CreateCompaniesModule1749200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'companies',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'varchar', length: '200' },
|
||||
{ name: 'type', type: 'varchar', length: '32' },
|
||||
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
|
||||
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
|
||||
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
|
||||
{ name: 'address', type: 'text', isNullable: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
|
||||
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
|
||||
{ name: 'attributes', type: 'jsonb', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'external_profiles',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'user_id', type: 'uuid' },
|
||||
{ name: 'company_id', type: 'uuid' },
|
||||
{ name: 'first_name', type: 'varchar', length: '100' },
|
||||
{ name: 'last_name', type: 'varchar', length: '100' },
|
||||
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
|
||||
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
|
||||
{ name: 'is_primary_contact', type: 'boolean', default: false },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'ff_clients',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'forwarder_company_id', type: 'uuid' },
|
||||
{ name: 'client_company_id', type: 'uuid' },
|
||||
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
|
||||
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
|
||||
{ name: 'can_view_documents', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['forwarder_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
{
|
||||
columnNames: ['client_company_id'],
|
||||
referencedTableName: 'companies',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
|
||||
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
|
||||
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
|
||||
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
|
||||
|
||||
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
|
||||
columnNames: ['forwarder_company_id', 'client_company_id'],
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.ff_clients');
|
||||
await queryRunner.dropTable('freight.external_profiles');
|
||||
await queryRunner.dropTable('freight.companies');
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingFreightType1749300000000 implements MigrationInterface {
|
||||
name = 'AddBookingFreightType1749300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET freight_type = 'BULK'
|
||||
WHERE freight_type IS NULL
|
||||
AND b.cargo_type_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.cargo_types ct
|
||||
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET freight_type = 'CONTAINER'
|
||||
WHERE freight_type IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN freight_type SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT chk_bookings_freight_type
|
||||
CHECK (freight_type IN ('CONTAINER', 'BULK'));
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET cargo_type_id = (
|
||||
SELECT id FROM freight.cargo_types LIMIT 1
|
||||
) WHERE cargo_type_id IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN cargo_type_id SET NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
|
||||
name = 'AddFanNumberToCompanies1749300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// fan_number may already exist when CreateCompaniesModule ran with the full schema
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS fan_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddContractSignatures1749400000000 implements MigrationInterface {
|
||||
name = 'AddContractSignatures1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
|
||||
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
signer_role VARCHAR(20) NOT NULL,
|
||||
signer_user_id UUID,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
|
||||
consent_text TEXT,
|
||||
ip_address VARCHAR(64),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT uq_booking_contract_signatures_role
|
||||
UNIQUE (booking_id, signer_role)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
|
||||
ON freight.booking_contract_signatures(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS pricing_breakdown,
|
||||
DROP COLUMN IF EXISTS contract_generated_at,
|
||||
DROP COLUMN IF EXISTS contract_template_key;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTrainScheduling1749400000000 implements MigrationInterface {
|
||||
name = 'AddTrainScheduling1749400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_types (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
max_wagons_per_train INT NULL,
|
||||
supported_load_types TEXT[] NOT NULL DEFAULT '{}',
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.locomotives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(100) NULL,
|
||||
max_pull_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
|
||||
available_from TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_sets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
locomotive_id UUID NOT NULL,
|
||||
total_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
total_length_meters NUMERIC(10,3) NOT NULL,
|
||||
wagon_count INT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id)
|
||||
REFERENCES freight.locomotives(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_set_wagons (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL,
|
||||
wagon_type_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
capacity_tons NUMERIC(10,3) NOT NULL,
|
||||
length_meters NUMERIC(10,3) NOT NULL,
|
||||
assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no),
|
||||
CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id)
|
||||
REFERENCES freight.wagon_types(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_id UUID NOT NULL UNIQUE,
|
||||
origin_station_id UUID NOT NULL,
|
||||
destination_station_id UUID NOT NULL,
|
||||
scheduled_departure_date TIMESTAMPTZ NOT NULL,
|
||||
scheduled_arrival_date TIMESTAMPTZ NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id)
|
||||
REFERENCES freight.train_sets(id),
|
||||
CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id)
|
||||
REFERENCES freight.yards(id),
|
||||
CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id)
|
||||
REFERENCES freight.yards(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id),
|
||||
CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id)
|
||||
REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_set_wagon_id UUID NOT NULL,
|
||||
booking_id UUID NOT NULL,
|
||||
allocated_weight_tons NUMERIC(10,3) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id)
|
||||
REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_status
|
||||
ON freight.locomotives(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_sets_status
|
||||
ON freight.train_sets(status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status
|
||||
ON freight.train_schedules(scheduled_departure_date, status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking
|
||||
ON freight.wagon_booking_allocations(booking_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
|
||||
name = 'AddCompanyIdToBookings1749500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
|
||||
ON freight.bookings(company_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_id"
|
||||
FOREIGN KEY (company_id)
|
||||
REFERENCES freight.companies(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN customer_id SET NOT NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface {
|
||||
name = 'AddBlocksRoleToApprovalStep1749600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_approval_step
|
||||
DROP COLUMN IF EXISTS blocks_role;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Seed ITMLS US-06 approval chains if missing (standard + bulk).
|
||||
*/
|
||||
export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface {
|
||||
name = 'SeedDefaultApprovalRules1749700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
INSERT INTO freight.approval_rules
|
||||
(id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at)
|
||||
SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.approval_rules
|
||||
WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// Keep seeded rules on rollback to avoid breaking in-flight bookings.
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* shipping_lines was created without a unique index on code; seeder upserts require it.
|
||||
*/
|
||||
export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface {
|
||||
name = 'AddShippingLinesCodeUniqueIndex1749800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await queryRunner.createIndex(
|
||||
'freight.shipping_lines',
|
||||
new TableIndex({
|
||||
name: 'UQ_shipping_lines_code',
|
||||
columnNames: ['code'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code');
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables.
|
||||
*/
|
||||
export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface {
|
||||
name = 'CreateFileUploadSettingsTables1749900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
code VARCHAR(128) NOT NULL,
|
||||
label VARCHAR(256) NOT NULL,
|
||||
description TEXT,
|
||||
entity VARCHAR(32) NOT NULL DEFAULT 'other',
|
||||
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 "UQ_file_upload_settings_code"
|
||||
ON freight.file_upload_settings (code);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.file_upload_fields (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
setting_id UUID NOT NULL,
|
||||
file_key VARCHAR(128) NOT NULL,
|
||||
file_label VARCHAR(256) NOT NULL,
|
||||
help_text TEXT,
|
||||
is_required BOOLEAN NOT NULL DEFAULT false,
|
||||
is_multiple BOOLEAN NOT NULL DEFAULT false,
|
||||
max_files INTEGER NOT NULL DEFAULT 1,
|
||||
allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[],
|
||||
max_size_mb INTEGER NOT NULL DEFAULT 10,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0),
|
||||
CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0),
|
||||
CONSTRAINT "FK_file_upload_fields_setting"
|
||||
FOREIGN KEY (setting_id)
|
||||
REFERENCES freight.file_upload_settings(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key"
|
||||
ON freight.file_upload_fields (setting_id, file_key);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyContactColumns1750000000000 implements MigrationInterface {
|
||||
name = 'AddCompanyContactColumns1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_name VARCHAR(100);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS contact_person_phone VARCHAR(20);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_name VARCHAR(100);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_email VARCHAR(150);`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies ADD COLUMN IF NOT EXISTS general_manager_phone VARCHAR(20);`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_phone;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_email;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS general_manager_name;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_phone;`);
|
||||
await queryRunner.query(`ALTER TABLE freight.companies DROP COLUMN IF EXISTS contact_person_name;`);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Train entity gained extended fields; baseline trains table only had code/capacity/status/notes.
|
||||
*/
|
||||
export class AddTrainExtendedColumns1750000000000 implements MigrationInterface {
|
||||
name = 'AddTrainExtendedColumns1750000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS train_name VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS origin_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS destination_station_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS remarks TEXT;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number"
|
||||
ON freight.trains (train_number)
|
||||
WHERE train_number IS NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.trains
|
||||
DROP COLUMN IF EXISTS remarks,
|
||||
DROP COLUMN IF EXISTS locomotive_number,
|
||||
DROP COLUMN IF EXISTS arrival_time,
|
||||
DROP COLUMN IF EXISTS departure_time,
|
||||
DROP COLUMN IF EXISTS destination_station_id,
|
||||
DROP COLUMN IF EXISTS origin_station_id,
|
||||
DROP COLUMN IF EXISTS route_id,
|
||||
DROP COLUMN IF EXISTS train_name,
|
||||
DROP COLUMN IF EXISTS train_number;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateFacilitiesTable1750000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'facilities',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
generationStrategy: 'uuid',
|
||||
default: 'gen_random_uuid()',
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
type: 'varchar',
|
||||
length: '40',
|
||||
isUnique: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'varchar',
|
||||
length: '160',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'facility_type',
|
||||
type: 'varchar',
|
||||
length: '32',
|
||||
},
|
||||
{
|
||||
name: 'facility_status',
|
||||
type: 'varchar',
|
||||
length: '32',
|
||||
default: "'ACTIVE'",
|
||||
},
|
||||
{
|
||||
name: 'location_name',
|
||||
type: 'varchar',
|
||||
length: '200',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'varchar',
|
||||
length: '100',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'varchar',
|
||||
length: '100',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'latitude',
|
||||
type: 'numeric',
|
||||
precision: 10,
|
||||
scale: 8,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'longitude',
|
||||
type: 'numeric',
|
||||
precision: 11,
|
||||
scale: 8,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'capacity',
|
||||
type: 'numeric',
|
||||
precision: 14,
|
||||
scale: 3,
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'is_active',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
{
|
||||
name: 'deleted_at',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.facilities',
|
||||
new TableIndex({
|
||||
name: 'idx_facilities_code',
|
||||
columnNames: ['code'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.facilities',
|
||||
new TableIndex({
|
||||
name: 'idx_facilities_status',
|
||||
columnNames: ['facility_status'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.facilities');
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
|
||||
|
||||
export class AddFacilityIdToWarehouses1750000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.warehouses');
|
||||
if (!table) {
|
||||
// warehouses table doesn't exist yet, skip this migration
|
||||
return;
|
||||
}
|
||||
|
||||
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
|
||||
if (hasColumn) {
|
||||
// Column already exists, skip
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.addColumn(
|
||||
'freight.warehouses',
|
||||
new TableColumn({
|
||||
name: 'facility_id',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.warehouses',
|
||||
new TableForeignKey({
|
||||
columnNames: ['facility_id'],
|
||||
referencedColumnNames: ['id'],
|
||||
referencedTableName: 'facilities',
|
||||
referencedSchema: 'freight',
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.warehouses');
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
|
||||
if (foreignKey) {
|
||||
await queryRunner.dropForeignKey('freight.warehouses', foreignKey);
|
||||
}
|
||||
const hasColumn = table.columns.some((col) => col.name === 'facility_id');
|
||||
if (hasColumn) {
|
||||
await queryRunner.dropColumn('freight.warehouses', 'facility_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Proof of Delivery (customer pickup) capture on cargoes:
|
||||
* receiver name, delivered/picked-up timestamp, and delivery remarks.
|
||||
*/
|
||||
export class AddProofOfDeliveryToCargoes1750000000002 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.cargoes');
|
||||
if (!table) {
|
||||
// cargoes table doesn't exist yet, skip this migration
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsToAdd = [
|
||||
{ name: 'receiver_name', type: 'varchar', isNullable: true },
|
||||
{ name: 'delivered_at', type: 'timestamp', isNullable: true },
|
||||
{ name: 'delivery_remarks', type: 'text', isNullable: true },
|
||||
];
|
||||
|
||||
const columnsToCreate = columnsToAdd.filter(
|
||||
(col) => !table.columns.some((c) => c.name === col.name),
|
||||
);
|
||||
|
||||
if (columnsToCreate.length > 0) {
|
||||
await queryRunner.addColumns(
|
||||
'freight.cargoes',
|
||||
columnsToCreate.map((col) => new TableColumn(col)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('freight.cargoes');
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnNames = ['receiver_name', 'delivered_at', 'delivery_remarks'];
|
||||
const columnsToRemove = columnNames.filter((name) =>
|
||||
table.columns.some((c) => c.name === name),
|
||||
);
|
||||
|
||||
if (columnsToRemove.length > 0) {
|
||||
await queryRunner.dropColumns('freight.cargoes', columnsToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 4.5 — warehouse inspection reports + inventory inspection status.
|
||||
*/
|
||||
export class AddWarehouseInspection1750000000003 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// inventory.inspection_status
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
|
||||
if (!hasColumn) {
|
||||
await queryRunner.addColumn(
|
||||
'freight.warehouse_inventory',
|
||||
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// warehouse_inspection_reports table
|
||||
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
|
||||
if (!inspectionTable) {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_inspection_reports',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'inventory_id', type: 'uuid' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" },
|
||||
{ name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" },
|
||||
{ name: 'has_damage', type: 'boolean', default: false },
|
||||
{ name: 'damage_description', type: 'text', isNullable: true },
|
||||
{ name: 'has_weight_loss', type: 'boolean', default: false },
|
||||
{ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true },
|
||||
{ name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'has_missing_items', type: 'boolean', default: false },
|
||||
{ name: 'missing_items_description', type: 'text', isNullable: true },
|
||||
{ name: 'remarks', type: 'text', isNullable: true },
|
||||
{ name: 'inspected_by_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'inspected_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_wir_inventory', columnNames: ['inventory_id'] },
|
||||
{ name: 'idx_wir_booking', columnNames: ['booking_id'] },
|
||||
{ name: 'idx_wir_status', columnNames: ['inspection_status'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const inspectionTable = await queryRunner.getTable('freight.warehouse_inspection_reports');
|
||||
if (inspectionTable) {
|
||||
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
|
||||
}
|
||||
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const hasColumn = inventoryTable.columns.some((col) => col.name === 'inspection_status');
|
||||
if (hasColumn) {
|
||||
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
|
||||
name = 'AddRoutesAndExtendLocomotives1750100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
|
||||
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
|
||||
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'OUT_OF_SERVICE'
|
||||
WHERE status = 'INACTIVE';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(120) NOT NULL UNIQUE,
|
||||
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.route_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL REFERENCES freight.yards(id),
|
||||
sequence_no INT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
|
||||
ON freight.routes(origin_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
|
||||
ON freight.routes(destination_yard_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_is_active
|
||||
ON freight.routes(is_active);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
|
||||
ON freight.route_milestones(route_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
|
||||
ON freight.route_milestones(yard_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS max_speed_kmh,
|
||||
DROP COLUMN IF EXISTS traction_force_kn,
|
||||
DROP COLUMN IF EXISTS power_kw,
|
||||
DROP COLUMN IF EXISTS max_train_length_meters,
|
||||
DROP COLUMN IF EXISTS locomotive_type;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.locomotives
|
||||
SET status = 'INACTIVE'
|
||||
WHERE status = 'OUT_OF_SERVICE';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateFleetCrudTables1750100000000 implements MigrationInterface {
|
||||
name = 'CreateFleetCrudTables1750100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS freight`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagons (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
wagon_number VARCHAR NOT NULL UNIQUE,
|
||||
wagon_type_id UUID NOT NULL,
|
||||
train_id UUID,
|
||||
sequence_number INT,
|
||||
tare_weight NUMERIC(10, 2) NOT NULL,
|
||||
max_payload_weight NUMERIC(10, 2) NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.containers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
container_number VARCHAR NOT NULL UNIQUE,
|
||||
container_type_id UUID NOT NULL,
|
||||
wagon_id UUID,
|
||||
position INT,
|
||||
tare_weight NUMERIC(10, 2) NOT NULL,
|
||||
max_gross_weight NUMERIC(10, 2) NOT NULL,
|
||||
seal_number VARCHAR,
|
||||
status VARCHAR NOT NULL DEFAULT 'AVAILABLE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.cargoes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
cargo_reference VARCHAR NOT NULL UNIQUE,
|
||||
shipment_id UUID NOT NULL,
|
||||
container_id UUID NOT NULL,
|
||||
cargo_type_id UUID,
|
||||
description TEXT,
|
||||
quantity NUMERIC(12, 3) NOT NULL,
|
||||
weight NUMERIC(10, 2) NOT NULL,
|
||||
volume NUMERIC(10, 2),
|
||||
status VARCHAR NOT NULL DEFAULT 'PENDING',
|
||||
loaded_at TIMESTAMP,
|
||||
unloaded_at TIMESTAMP,
|
||||
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_wagons_train_id" ON freight.wagons (train_id)`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_containers_wagon_id" ON freight.containers (wagon_id)`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_cargoes_container_id" ON freight.cargoes (container_id)`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagons_train_id"
|
||||
FOREIGN KEY (train_id) REFERENCES freight.trains(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagons_wagon_type_id"
|
||||
FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT "FK_containers_wagon_id"
|
||||
FOREIGN KEY (wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT "FK_containers_container_type_id"
|
||||
FOREIGN KEY (container_type_id) REFERENCES freight.container_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT "FK_cargoes_container_id"
|
||||
FOREIGN KEY (container_id) REFERENCES freight.containers(id) ON DELETE RESTRICT;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT "FK_cargoes_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id);
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.cargoes CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.containers CASCADE`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagons CASCADE`);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPhysicalWagonToTrainSetWagons1750200000000 implements MigrationInterface {
|
||||
name = 'AddPhysicalWagonToTrainSetWagons1750200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'freight'
|
||||
AND table_name = 'train_set_wagons'
|
||||
AND constraint_name = 'fk_train_set_wagons_physical_wagon'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
|
||||
FOREIGN KEY (physical_wagon_id)
|
||||
REFERENCES freight.wagons(id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
|
||||
ON freight.train_set_wagons(physical_wagon_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_set_wagons_physical_wagon;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP CONSTRAINT IF EXISTS fk_train_set_wagons_physical_wagon;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS physical_wagon_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SeedDefaultWagonTypes1750200000000 implements MigrationInterface {
|
||||
name = 'SeedDefaultWagonTypes1750200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true),
|
||||
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true),
|
||||
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true),
|
||||
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true),
|
||||
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true),
|
||||
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true),
|
||||
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true),
|
||||
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true),
|
||||
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true),
|
||||
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.wagon_types
|
||||
WHERE code IN ('NW7', 'NW5', 'PW2', 'GW2', 'CW4', 'CW3', 'KW2', 'KW3', 'NW6', 'BW1');
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCurrentLocationToWagons1750300000000 implements MigrationInterface {
|
||||
name = 'AddCurrentLocationToWagons1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS current_location_yard_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = 'freight'
|
||||
AND table_name = 'wagons'
|
||||
AND constraint_name = 'FK_wagons_current_location_yard_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagons_current_location_yard_id"
|
||||
FOREIGN KEY (current_location_yard_id)
|
||||
REFERENCES freight.yards(id)
|
||||
ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_wagons_current_location_yard_id"
|
||||
ON freight.wagons(current_location_yard_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagons_current_location_yard_id";`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP CONSTRAINT IF EXISTS "FK_wagons_current_location_yard_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS current_location_yard_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
|
||||
name = 'AddRouteToTrainSchedules1750300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_train_schedules_route'
|
||||
) THEN
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD CONSTRAINT fk_train_schedules_route
|
||||
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
|
||||
ON freight.train_schedules(route_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS route_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSchedulingAllocationEnhancements1750400000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddSchedulingAllocationEnhancements1750400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS wagons_required NUMERIC(6,2) NULL,
|
||||
ADD COLUMN IF NOT EXISTS scheduling_status VARCHAR(30) NOT NULL DEFAULT 'NOT_SCHEDULED',
|
||||
ADD COLUMN IF NOT EXISTS hold_started_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS hold_expires_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS train_number VARCHAR(20) NULL,
|
||||
ADD COLUMN IF NOT EXISTS direction VARCHAR(10) NULL,
|
||||
ADD COLUMN IF NOT EXISTS actual_departure_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS actual_arrival_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS prepared_by_user_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS checked_by_user_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_wagons INT NOT NULL DEFAULT 53;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD COLUMN IF NOT EXISTS physical_wagon_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL,
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PLANNED',
|
||||
ADD COLUMN IF NOT EXISTS confirmed_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS confirmed_by_user_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
ADD COLUMN IF NOT EXISTS equated_length_m NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS tare_weight_tons NUMERIC(10,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS supports_container BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS max_container_gross_t NUMERIC(10,3) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS train_set_wagon_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS current_train_schedule_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.containers
|
||||
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS booking_container_id UUID NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD COLUMN IF NOT EXISTS wagon_booking_allocation_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS booking_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS load_type VARCHAR(20) NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ALTER COLUMN container_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_container_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_booking_allocation_id UUID NOT NULL,
|
||||
booking_container_id UUID NULL,
|
||||
container_id UUID NULL,
|
||||
container_number VARCHAR(64) NULL,
|
||||
container_type_id UUID NOT NULL,
|
||||
position_on_wagon SMALLINT NULL,
|
||||
seal_number VARCHAR(64) NULL,
|
||||
chassis_number VARCHAR(64) NULL,
|
||||
gross_weight_tons NUMERIC(10,3) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_waci_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_waci_booking_container FOREIGN KEY (booking_container_id)
|
||||
REFERENCES freight.booking_container(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_waci_container FOREIGN KEY (container_id)
|
||||
REFERENCES freight.containers(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_waci_container_type FOREIGN KEY (container_type_id)
|
||||
REFERENCES freight.container_types(id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.wagon_allocation_bulk_loads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
wagon_booking_allocation_id UUID NOT NULL UNIQUE,
|
||||
booking_id UUID NOT NULL,
|
||||
cargo_type_id UUID NULL,
|
||||
cargo_description TEXT NULL,
|
||||
pricing_unit VARCHAR(20) NOT NULL DEFAULT 'PER_TON',
|
||||
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
|
||||
weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0,
|
||||
truck_plate_number VARCHAR(32) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT fk_wabl_allocation FOREIGN KEY (wagon_booking_allocation_id)
|
||||
REFERENCES freight.wagon_booking_allocations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_wabl_booking FOREIGN KEY (booking_id)
|
||||
REFERENCES freight.bookings(id),
|
||||
CONSTRAINT fk_wabl_cargo_type FOREIGN KEY (cargo_type_id)
|
||||
REFERENCES freight.cargo_types(id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_scheduling_status
|
||||
ON freight.bookings(scheduling_status);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_train_number
|
||||
ON freight.train_schedules(train_number);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_set_wagons_physical_wagon
|
||||
ON freight.train_set_wagons(physical_wagon_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_train_set_wagon_id
|
||||
ON freight.wagons(train_set_wagon_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_current_train_schedule_id
|
||||
ON freight.wagons(current_train_schedule_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_waci_allocation
|
||||
ON freight.wagon_allocation_container_items(wagon_booking_allocation_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wabl_booking
|
||||
ON freight.wagon_allocation_bulk_loads(booking_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
ADD CONSTRAINT fk_train_set_wagons_physical_wagon
|
||||
FOREIGN KEY (physical_wagon_id) REFERENCES freight.wagons(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT fk_wagons_train_set_wagon
|
||||
FOREIGN KEY (train_set_wagon_id) REFERENCES freight.train_set_wagons(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT fk_wagons_current_train_schedule
|
||||
FOREIGN KEY (current_train_schedule_id) REFERENCES freight.train_schedules(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_wagon_allocation
|
||||
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.containers
|
||||
ADD CONSTRAINT fk_containers_booking_container
|
||||
FOREIGN KEY (booking_container_id) REFERENCES freight.booking_container(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT fk_cargoes_wagon_allocation
|
||||
FOREIGN KEY (wagon_booking_allocation_id) REFERENCES freight.wagon_booking_allocations(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE freight.cargoes
|
||||
ADD CONSTRAINT fk_cargoes_booking
|
||||
FOREIGN KEY (booking_id) REFERENCES freight.bookings(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 22.4,
|
||||
supports_container = true,
|
||||
max_container_gross_t = 30.48
|
||||
WHERE code = 'NW5';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.6,
|
||||
tare_weight_tons = 25.2,
|
||||
supports_container = false
|
||||
WHERE code = 'PW2';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.5,
|
||||
tare_weight_tons = 25.2,
|
||||
supports_container = false
|
||||
WHERE code = 'KW2';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 23.4,
|
||||
supports_container = false
|
||||
WHERE code = 'CW3';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types SET
|
||||
equated_length_m = 1.3,
|
||||
tare_weight_tons = 24.8,
|
||||
supports_container = false
|
||||
WHERE code = 'CW4';
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_bulk_loads;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_allocation_container_items;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
ALTER COLUMN container_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS wagons_required,
|
||||
DROP COLUMN IF EXISTS scheduling_status,
|
||||
DROP COLUMN IF EXISTS hold_started_at,
|
||||
DROP COLUMN IF EXISTS hold_expires_at,
|
||||
DROP COLUMN IF EXISTS scheduled_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
DROP COLUMN IF EXISTS train_number,
|
||||
DROP COLUMN IF EXISTS direction,
|
||||
DROP COLUMN IF EXISTS actual_departure_at,
|
||||
DROP COLUMN IF EXISTS actual_arrival_at,
|
||||
DROP COLUMN IF EXISTS prepared_by_user_id,
|
||||
DROP COLUMN IF EXISTS checked_by_user_id,
|
||||
DROP COLUMN IF EXISTS max_wagons;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_set_wagons
|
||||
DROP COLUMN IF EXISTS physical_wagon_id,
|
||||
DROP COLUMN IF EXISTS status;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_booking_allocations
|
||||
DROP COLUMN IF EXISTS load_type,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS confirmed_at,
|
||||
DROP COLUMN IF EXISTS confirmed_by_user_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_types
|
||||
DROP COLUMN IF EXISTS equated_length_m,
|
||||
DROP COLUMN IF EXISTS tare_weight_tons,
|
||||
DROP COLUMN IF EXISTS supports_container,
|
||||
DROP COLUMN IF EXISTS max_container_gross_t;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS train_set_wagon_id,
|
||||
DROP COLUMN IF EXISTS current_train_schedule_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.containers
|
||||
DROP COLUMN IF EXISTS booking_id,
|
||||
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||
DROP COLUMN IF EXISTS booking_container_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.cargoes
|
||||
DROP COLUMN IF EXISTS wagon_booking_allocation_id,
|
||||
DROP COLUMN IF EXISTS booking_id,
|
||||
DROP COLUMN IF EXISTS load_type;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type FleetRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
count: number;
|
||||
start: number;
|
||||
end: number;
|
||||
capacityTons: number;
|
||||
tareWeight: number;
|
||||
lengthMeters: number;
|
||||
supportedLoadTypes: string[];
|
||||
};
|
||||
|
||||
const FLEET: FleetRow[] = [
|
||||
{
|
||||
code: 'PW2',
|
||||
name: 'Box wagon',
|
||||
count: 220,
|
||||
start: 1,
|
||||
end: 220,
|
||||
capacityTons: 70,
|
||||
tareWeight: 25.2,
|
||||
lengthMeters: 17.066,
|
||||
supportedLoadTypes: ['BULK', 'GENERAL_CARGO', 'BAGGED_CARGO', 'BOXED_CARGO'],
|
||||
},
|
||||
{
|
||||
code: 'CW4',
|
||||
name: 'Gondola wagon covered',
|
||||
count: 110,
|
||||
start: 221,
|
||||
end: 330,
|
||||
capacityTons: 70,
|
||||
tareWeight: 24.8,
|
||||
lengthMeters: 13.976,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
},
|
||||
{
|
||||
code: 'CW3',
|
||||
name: 'Gondola wagon',
|
||||
count: 20,
|
||||
start: 331,
|
||||
end: 350,
|
||||
capacityTons: 70,
|
||||
tareWeight: 23.4,
|
||||
lengthMeters: 13.976,
|
||||
supportedLoadTypes: ['BULK', 'COAL', 'ORE'],
|
||||
},
|
||||
{
|
||||
code: 'KW2',
|
||||
name: 'Hopper wagon covered',
|
||||
count: 20,
|
||||
start: 351,
|
||||
end: 370,
|
||||
capacityTons: 69,
|
||||
tareWeight: 25.2,
|
||||
lengthMeters: 16.466,
|
||||
supportedLoadTypes: ['BULK', 'GRAIN'],
|
||||
},
|
||||
{
|
||||
code: 'KW3',
|
||||
name: 'Hopper wagon',
|
||||
count: 20,
|
||||
start: 371,
|
||||
end: 390,
|
||||
capacityTons: 70,
|
||||
tareWeight: 24,
|
||||
lengthMeters: 14.4,
|
||||
supportedLoadTypes: ['BULK', 'COAL'],
|
||||
},
|
||||
{
|
||||
code: 'NW5',
|
||||
name: 'Flat wagon container',
|
||||
count: 550,
|
||||
start: 391,
|
||||
end: 940,
|
||||
capacityTons: 70,
|
||||
tareWeight: 0,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
},
|
||||
];
|
||||
|
||||
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||
|
||||
export class SeedEdRWagonFleet1750400000000 implements MigrationInterface {
|
||||
name = 'SeedEdRWagonFleet1750400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.wagon_types
|
||||
SET name = 'Flat wagon container',
|
||||
capacity_tons = 70,
|
||||
length_meters = 14.000,
|
||||
supported_load_types = ARRAY['CONTAINER'],
|
||||
max_wagons_per_train = 53,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE code = 'NW5';
|
||||
`);
|
||||
|
||||
const [defaultLocation] = await queryRunner.query(`
|
||||
SELECT id
|
||||
FROM freight.yards
|
||||
WHERE code IN ('DJIBOUTI', 'DJIB_PORT', 'NAGAD')
|
||||
OR lower(label) LIKE '%djibouti%'
|
||||
ORDER BY
|
||||
CASE code
|
||||
WHEN 'DJIBOUTI' THEN 1
|
||||
WHEN 'DJIB_PORT' THEN 2
|
||||
WHEN 'NAGAD' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
display_order ASC
|
||||
LIMIT 1;
|
||||
`);
|
||||
const defaultLocationYardId = defaultLocation?.id ?? null;
|
||||
|
||||
for (const row of FLEET) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::text[], true)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
row.code,
|
||||
row.name,
|
||||
row.capacityTons,
|
||||
row.lengthMeters,
|
||||
row.supportedLoadTypes.includes('CONTAINER') ? 53 : 37,
|
||||
row.supportedLoadTypes,
|
||||
],
|
||||
);
|
||||
|
||||
const [typeRecord] = await queryRunner.query(
|
||||
`SELECT id FROM freight.wagon_types WHERE code = $1 LIMIT 1;`,
|
||||
[row.code],
|
||||
);
|
||||
|
||||
if (!typeRecord?.id) {
|
||||
throw new Error(`wagon_type_seed_failed:${row.code}`);
|
||||
}
|
||||
|
||||
if (row.end - row.start + 1 !== row.count) {
|
||||
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||
}
|
||||
|
||||
for (let sequence = row.start; sequence <= row.end; sequence += 1) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagons (
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
tare_weight,
|
||||
max_payload_weight,
|
||||
current_location_yard_id,
|
||||
status,
|
||||
notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (wagon_number) DO UPDATE SET
|
||||
wagon_type_id = EXCLUDED.wagon_type_id,
|
||||
tare_weight = EXCLUDED.tare_weight,
|
||||
max_payload_weight = EXCLUDED.max_payload_weight,
|
||||
current_location_yard_id = CASE
|
||||
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.current_location_yard_id
|
||||
ELSE freight.wagons.current_location_yard_id
|
||||
END,
|
||||
status = CASE
|
||||
WHEN freight.wagons.train_id IS NULL THEN EXCLUDED.status
|
||||
ELSE freight.wagons.status
|
||||
END,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = now();
|
||||
`,
|
||||
[
|
||||
wagonNumber(sequence),
|
||||
typeRecord.id,
|
||||
row.tareWeight,
|
||||
row.capacityTons,
|
||||
defaultLocationYardId,
|
||||
defaultLocationYardId ? 'IMPORT_READY' : 'AVAILABLE',
|
||||
`Seeded Ethio-Djibouti Railway ${row.code} fleet record.`,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.wagons
|
||||
WHERE wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWagonReadiness1750500000000 implements MigrationInterface {
|
||||
name = 'AddWagonReadiness1750500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
|
||||
ON freight.wagons (readiness)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
DROP COLUMN IF EXISTS readiness
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddGovernmentBookingFields1750600000000 implements MigrationInterface {
|
||||
name = 'AddGovernmentBookingFields1750600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS is_government BOOLEAN NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS government_institution VARCHAR(255) NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN company_id DROP NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_is_government
|
||||
ON freight.bookings (is_government)
|
||||
WHERE is_government = true AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_is_government`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET company_id = '00000000-0000-0000-0000-000000000000'
|
||||
WHERE company_id IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ALTER COLUMN company_id SET NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS government_institution
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS is_government
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSchedulingEvents1750700000000 implements MigrationInterface {
|
||||
name = 'CreateSchedulingEvents1750700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.scheduling_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL,
|
||||
trigger VARCHAR(40) NOT NULL,
|
||||
actor_user_id UUID NULL,
|
||||
reason TEXT NULL,
|
||||
plan_snapshot JSONB NOT NULL DEFAULT '{}',
|
||||
displaced_booking_ids JSONB NOT NULL DEFAULT '[]',
|
||||
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_scheduling_events_train_schedule_id
|
||||
ON freight.scheduling_events (train_schedule_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_scheduling_events_train_schedule_id`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.scheduling_events`);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** 20ft = 0.5 wagon slots (2 per wagon); 40ft = 1.0 wagon slot (1 per wagon). */
|
||||
export class FixContainerWagonsPerUnit1750800000000 implements MigrationInterface {
|
||||
name = 'FixContainerWagonsPerUnit1750800000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||
if (!hasContainerTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = 0.50
|
||||
WHERE size_ft = 20 OR code LIKE '20%';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = 1.00
|
||||
WHERE size_ft = 40 OR code LIKE '40%';
|
||||
`);
|
||||
|
||||
const hasBookingContainer = await queryRunner.hasTable('freight.booking_container');
|
||||
if (!hasBookingContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_container bc
|
||||
SET wagons_required = CEILING(bc.quantity * ct.wagons_per_unit)
|
||||
FROM freight.container_types ct
|
||||
WHERE ct.id = bc.container_type_id;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const hasContainerTypes = await queryRunner.hasTable('freight.container_types');
|
||||
if (!hasContainerTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types SET wagons_per_unit = 1.00;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddContainerNumberToBookingContainer1750900000000 implements MigrationInterface {
|
||||
name = "AddContainerNumberToBookingContainer1750900000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD COLUMN container_number varchar(64);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_allocation_container_items
|
||||
ALTER COLUMN container_type_id DROP NOT NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagon_allocation_container_items
|
||||
ALTER COLUMN container_type_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP COLUMN container_number;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ALTER COLUMN container_type_id SET NOT NULL;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateTrainSchedulingGlobalRules1751000000000 implements MigrationInterface {
|
||||
name = "CreateTrainSchedulingGlobalRules1751000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.train_scheduling_global_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
max_train_length_meters numeric(10, 2) NOT NULL DEFAULT 760,
|
||||
max_train_weight_tons numeric(10, 3) NOT NULL DEFAULT 3500,
|
||||
max_wagons_per_train integer NOT NULL DEFAULT 53,
|
||||
max_20ft_container_weight_tons numeric(8, 3) NOT NULL DEFAULT 30,
|
||||
max_20ft_pair_weight_diff_tons numeric(8, 3) NOT NULL DEFAULT 10,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.train_scheduling_global_rules (
|
||||
max_train_length_meters,
|
||||
max_train_weight_tons,
|
||||
max_wagons_per_train,
|
||||
max_20ft_container_weight_tons,
|
||||
max_20ft_pair_weight_diff_tons
|
||||
) VALUES (760, 3500, 53, 30, 10);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_scheduling_global_rules;`);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddDeletedAtToTrainSchedulingGlobalRules1751000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddDeletedAtToTrainSchedulingGlobalRules1751000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_scheduling_global_rules
|
||||
DROP COLUMN IF EXISTS deleted_at;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
MigrationInterface,
|
||||
QueryRunner,
|
||||
Table,
|
||||
TableIndex,
|
||||
TableForeignKey,
|
||||
} from "typeorm";
|
||||
|
||||
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
|
||||
name = "CreateCompanyProfiles1752000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: "freight",
|
||||
name: "company_profiles",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
type: "uuid",
|
||||
isPrimary: true,
|
||||
generationStrategy: "uuid",
|
||||
default: "gen_random_uuid()",
|
||||
},
|
||||
{ name: "company_id", type: "uuid" },
|
||||
{ name: "type", type: "varchar", length: "32" },
|
||||
{ name: "reference", type: "varchar", length: "20", isUnique: true },
|
||||
{
|
||||
name: "status",
|
||||
type: "varchar",
|
||||
length: "32",
|
||||
default: "'active'",
|
||||
},
|
||||
{
|
||||
name: "business_license",
|
||||
type: "varchar",
|
||||
length: "100",
|
||||
isNullable: true,
|
||||
},
|
||||
{ name: "attributes", type: "jsonb", isNullable: true },
|
||||
{ name: "created_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "updated_at", type: "timestamptz", default: "now()" },
|
||||
{ name: "deleted_at", type: "timestamptz", isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
"freight.company_profiles",
|
||||
new TableForeignKey({
|
||||
columnNames: ["company_id"],
|
||||
referencedTableName: "companies",
|
||||
referencedSchema: "freight",
|
||||
referencedColumnNames: ["id"],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["company_id"] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
"freight.company_profiles",
|
||||
new TableIndex({ columnNames: ["type"] }),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable("freight.company_profiles");
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class MoveBusinessLicenseToProfile1752000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'MoveBusinessLicenseToProfile1752000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.company_profiles cp
|
||||
SET business_license = c.business_license
|
||||
FROM freight.companies c
|
||||
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies c
|
||||
SET business_license = cp.business_license
|
||||
FROM (
|
||||
SELECT DISTINCT ON (cp2.company_id)
|
||||
cp2.company_id, cp2.business_license
|
||||
FROM freight.company_profiles cp2
|
||||
WHERE cp2.business_license IS NOT NULL
|
||||
ORDER BY cp2.company_id, cp2.created_at
|
||||
) cp
|
||||
WHERE cp.company_id = c.id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateVehiclesTable1770000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vehicles' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
plate_number VARCHAR NOT NULL UNIQUE,
|
||||
registration_number VARCHAR NOT NULL UNIQUE,
|
||||
vehicle_type VARCHAR NOT NULL,
|
||||
manufacturer VARCHAR NOT NULL,
|
||||
model VARCHAR NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
fuel_type VARCHAR NOT NULL,
|
||||
capacity NUMERIC NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_vehicles_plate_number ON freight.vehicles(plate_number);
|
||||
CREATE INDEX idx_vehicles_registration_number ON freight.vehicles(registration_number);
|
||||
CREATE INDEX idx_vehicles_status ON freight.vehicles(status);
|
||||
CREATE INDEX idx_vehicles_vehicle_type ON freight.vehicles(vehicle_type);
|
||||
CREATE INDEX idx_vehicles_manufacturer ON freight.vehicles(manufacturer);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.vehicles CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateDriversTable1775000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'drivers' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.drivers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_number VARCHAR NOT NULL UNIQUE,
|
||||
first_name VARCHAR NOT NULL,
|
||||
last_name VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL UNIQUE,
|
||||
phone_number VARCHAR NOT NULL UNIQUE,
|
||||
date_of_birth DATE NOT NULL,
|
||||
license_expiry_date DATE NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
vehicle_types_authorized VARCHAR[],
|
||||
address TEXT,
|
||||
emergency_contact VARCHAR,
|
||||
notes TEXT,
|
||||
total_trips INTEGER DEFAULT 0,
|
||||
rating NUMERIC(3, 2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_drivers_license_number ON freight.drivers(license_number);
|
||||
CREATE INDEX idx_drivers_email ON freight.drivers(email);
|
||||
CREATE INDEX idx_drivers_phone_number ON freight.drivers(phone_number);
|
||||
CREATE INDEX idx_drivers_status ON freight.drivers(status);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.drivers CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreatePaymentTable1780639311366 implements MigrationInterface {
|
||||
name = "CreatePaymentTable1780639311366";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_type_enum AS ENUM ('booking');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_currency_enum AS ENUM ('ETB', 'USD');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payments_status_enum AS ENUM (
|
||||
'action-required',
|
||||
'processing',
|
||||
'success',
|
||||
'failed',
|
||||
'canceled',
|
||||
'refunded'
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payments (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
|
||||
ref_id varchar(255) NOT NULL,
|
||||
|
||||
type freight.payments_type_enum NOT NULL,
|
||||
|
||||
method freight.payments_method_enum NOT NULL,
|
||||
|
||||
currency freight.payments_currency_enum NOT NULL,
|
||||
|
||||
amount numeric NOT NULL,
|
||||
|
||||
raw_initiation jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
client_action json,
|
||||
|
||||
merchant_order_id varchar(255) NOT NULL,
|
||||
|
||||
transaction_id varchar(255),
|
||||
|
||||
status freight.payments_status_enum NOT NULL DEFAULT 'action-required',
|
||||
|
||||
paid_at date,
|
||||
|
||||
refunded_at date,
|
||||
|
||||
expires_at date,
|
||||
|
||||
failer_code varchar(30),
|
||||
|
||||
failer_message varchar(255),
|
||||
|
||||
reason varchar(255),
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT PK_payments PRIMARY KEY (id),
|
||||
|
||||
CONSTRAINT UQ_payments_merchant_order_id UNIQUE (merchant_order_id),
|
||||
|
||||
CONSTRAINT UQ_payments_transaction_id UNIQUE (transaction_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TABLE IF EXISTS freight.payments;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_status_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_currency_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_method_enum;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TYPE IF EXISTS freight.payments_type_enum;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AlterClientActionToJsonb1780639978834 implements MigrationInterface {
|
||||
name = "AlterClientActionToJsonb1780639978834";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action TYPE jsonb
|
||||
USING client_action::jsonb;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action DROP DEFAULT;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN client_action TYPE json
|
||||
USING client_action::json;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class UpdatePaymentTimestamp1780644945086 implements MigrationInterface {
|
||||
name = "UpdatePaymentTimestamp1780644945086";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN refunded_at TYPE timestamp
|
||||
USING refunded_at::timestamp;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN expires_at TYPE timestamp
|
||||
USING expires_at::timestamp;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN refunded_at TYPE timestamptz
|
||||
USING refunded_at::timestamptz;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.payments
|
||||
ALTER COLUMN expires_at TYPE timestamptz
|
||||
USING expires_at::timestamptz;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLocomotiveReadiness1781000000000 implements MigrationInterface {
|
||||
name = 'AddLocomotiveReadiness1781000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
|
||||
ON freight.locomotives (readiness)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
DROP COLUMN IF EXISTS readiness
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateTrainCheckpointEvents1781000000001 implements MigrationInterface {
|
||||
name = 'CreateTrainCheckpointEvents1781000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_checkpoint_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id UUID NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||
yard_id UUID NOT NULL,
|
||||
sequence_no INT NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
note TEXT NULL,
|
||||
recorded_by_user_id UUID 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_train_checkpoint_events_schedule
|
||||
ON freight.train_checkpoint_events (train_schedule_id, sequence_no)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_checkpoint_events_schedule`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_checkpoint_events`);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBatchBookingFields1781000000002 implements MigrationInterface {
|
||||
name = 'AddBatchBookingFields1781000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Booking → target schedule (pool membership) + 1h pay-window deadline.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL,
|
||||
ADD COLUMN IF NOT EXISTS payment_deadline TIMESTAMPTZ NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_train_schedule_id
|
||||
ON freight.bookings (train_schedule_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
|
||||
// TrainSchedule → booking-window status (OPEN/FULL/CLOSED).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS booking_window_status VARCHAR(10) NOT NULL DEFAULT 'OPEN'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_schedules_booking_window_status
|
||||
ON freight.train_schedules (booking_window_status)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_train_schedules_booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS booking_window_status`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_train_schedule_id`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS train_schedule_id,
|
||||
DROP COLUMN IF EXISTS payment_deadline
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSelectedForBatchStatus1781000000003 implements MigrationInterface {
|
||||
name = 'AddSelectedForBatchStatus1781000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS selected_for_batch_at TIMESTAMPTZ NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET
|
||||
status = 'SELECTED_FOR_BATCH',
|
||||
selected_for_batch_at = COALESCE(
|
||||
payment_deadline - INTERVAL '5 minutes',
|
||||
updated_at
|
||||
)
|
||||
WHERE status = 'AWAITING_PAYMENT'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings
|
||||
SET status = 'AWAITING_PAYMENT'
|
||||
WHERE status = 'SELECTED_FOR_BATCH'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS selected_for_batch_at
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Allow DOMESTIC trade direction on weight_limit_rules (domestic corridor bookings).
|
||||
*/
|
||||
export class AddDomesticWeightLimitTradeDirection1781000000004
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddDomesticWeightLimitTradeDirection1781000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE freight.weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
WHEN undefined_object THEN
|
||||
BEGIN
|
||||
ALTER TYPE weight_limit_rules_trade_direction_enum ADD VALUE 'DOMESTIC';
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values safely.
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'train_composition_removal_logs',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'schedule_id',
|
||||
type: 'uuid',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'booking_id',
|
||||
type: 'uuid',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'booking_reference',
|
||||
type: 'varchar',
|
||||
length: '64',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'removed_by_user_id',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'removed_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamptz',
|
||||
default: 'NOW()',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'deleted_at',
|
||||
type: 'timestamptz',
|
||||
isNullable: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.train_composition_removal_logs',
|
||||
new TableIndex({
|
||||
columnNames: ['schedule_id'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.train_composition_removal_logs', true);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WagonLocomotiveYardLink1782000000000 implements MigrationInterface {
|
||||
name = 'WagonLocomotiveYardLink1782000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_wagon_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.wagons
|
||||
ADD CONSTRAINT "FK_wagon_current_yard"
|
||||
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_wagon_current_yard_id"
|
||||
ON freight.wagons ("current_yard_id");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wagons_readiness`);
|
||||
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS readiness;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS "current_yard_id" UUID NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_locomotive_current_yard'
|
||||
) THEN
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD CONSTRAINT "FK_locomotive_current_yard"
|
||||
FOREIGN KEY ("current_yard_id") REFERENCES freight.yards(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_locomotive_current_yard_id"
|
||||
ON freight.locomotives ("current_yard_id");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_locomotives_readiness`);
|
||||
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS readiness;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives
|
||||
ADD COLUMN IF NOT EXISTS readiness VARCHAR(20) NOT NULL DEFAULT 'IMPORT_READY';
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagons_readiness
|
||||
ON freight.wagons (readiness)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_locomotives_readiness
|
||||
ON freight.locomotives (readiness)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_wagon_current_yard_id"`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_locomotive_current_yard_id"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS "FK_wagon_current_yard";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.locomotives DROP CONSTRAINT IF EXISTS "FK_locomotive_current_yard";
|
||||
`);
|
||||
await queryRunner.query(`ALTER TABLE freight.wagons DROP COLUMN IF EXISTS "current_yard_id";`);
|
||||
await queryRunner.query(`ALTER TABLE freight.locomotives DROP COLUMN IF EXISTS "current_yard_id";`);
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPaymentWebhookEventAndRefund1782000000001 implements MigrationInterface {
|
||||
name = "AddPaymentWebhookEventAndRefund1782000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Enum for webhook provider — shares the same values as payments_method_enum
|
||||
// but is a separate type so both tables remain independently evolvable.
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE freight.payment_webhook_method_enum AS ENUM ('telebirr', 'cbe-birr', 'ebirr');
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payment_webhook_events (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
provider freight.payment_webhook_method_enum NOT NULL,
|
||||
external_event_id varchar(255) NOT NULL,
|
||||
merchant_order_id varchar(255),
|
||||
provider_txn_id varchar(255),
|
||||
signature_valid boolean NOT NULL,
|
||||
status varchar(100) NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
received_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMP,
|
||||
processing_error text,
|
||||
|
||||
CONSTRAINT PK_payment_webhook_events PRIMARY KEY (id),
|
||||
CONSTRAINT UQ_payment_webhook_events_provider_event UNIQUE (provider, external_event_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IDX_payment_webhook_events_merchant_order_id
|
||||
ON freight.payment_webhook_events (merchant_order_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.payment_refunds (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
payment_id uuid NOT NULL,
|
||||
amount_minor int NOT NULL,
|
||||
reason varchar(255),
|
||||
provider_refund_id varchar(255),
|
||||
status varchar(50) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT PK_payment_refunds PRIMARY KEY (id),
|
||||
CONSTRAINT FK_payment_refunds_payment
|
||||
FOREIGN KEY (payment_id)
|
||||
REFERENCES freight.payments (id)
|
||||
ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_refunds;`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.IDX_payment_webhook_events_merchant_order_id;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.payment_webhook_events;`);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS freight.payment_webhook_method_enum;`);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class ExtendPaymentMethodEnum1782000000002 implements MigrationInterface {
|
||||
name = "ExtendPaymentMethodEnum1782000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'waafi';`);
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'card';`);
|
||||
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'dmoney';`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// PostgreSQL does not support removing enum values directly.
|
||||
// To roll back, recreate the type without the added values and update the column.
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ReplacePriorityRulesWithPriorityConfigs1783000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.priority_configs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('WAGON', 'CURRENCY')),
|
||||
label VARCHAR(100) NOT NULL,
|
||||
currency VARCHAR(5) NULL,
|
||||
min_wagon_count INT NOT NULL,
|
||||
max_wagon_count INT NOT NULL,
|
||||
score_points INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT false,
|
||||
display_order INT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT chk_wagon_range CHECK (min_wagon_count <= max_wagon_count),
|
||||
CONSTRAINT chk_currency_for_type CHECK (
|
||||
(type = 'WAGON' AND currency IS NULL) OR
|
||||
(type = 'CURRENCY' AND currency IS NOT NULL)
|
||||
)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_type_active ON freight.priority_configs (type, is_active);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX idx_priority_configs_currency_type ON freight.priority_configs (currency, type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.priority_configs;`);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.saved_signatures (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL,
|
||||
signer_display_name VARCHAR(200) NOT NULL,
|
||||
signature_file_id UUID NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Full wagon re-seed — runs in this order:
|
||||
*
|
||||
* 1. DELETE all existing wagons (hard delete, not soft).
|
||||
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
|
||||
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
|
||||
* the 5 main operational yards (10 wagons per yard per type):
|
||||
*
|
||||
* KALITY — Kality Rail Terminal
|
||||
* MOJO — Mojo Dry Port
|
||||
* DIRE_DAWA — Dire Dawa Yard
|
||||
* DJIB_PORT — Djibouti Port Terminal
|
||||
* NAGAD — Nagad Terminal, Djibouti
|
||||
*
|
||||
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
|
||||
* Yard IDs are fetched live from freight.yards so the migration is safe across
|
||||
* all environments regardless of UUID values.
|
||||
*/
|
||||
export class SeedWagonsWithYardAssignment1784000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SeedWagonsWithYardAssignment1784000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
|
||||
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active,
|
||||
tare_weight_tons
|
||||
)
|
||||
VALUES
|
||||
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
|
||||
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
|
||||
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
|
||||
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
|
||||
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
|
||||
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
|
||||
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
|
||||
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
|
||||
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
|
||||
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
tare_weight_tons = EXCLUDED.tare_weight_tons,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`);
|
||||
|
||||
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
wt RECORD;
|
||||
yard_kality UUID;
|
||||
yard_mojo UUID;
|
||||
yard_dire_dawa UUID;
|
||||
yard_djib_port UUID;
|
||||
yard_nagad UUID;
|
||||
yards UUID[];
|
||||
i INT;
|
||||
yard_id UUID;
|
||||
wagon_num TEXT;
|
||||
v_tare NUMERIC;
|
||||
v_payload NUMERIC;
|
||||
BEGIN
|
||||
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
|
||||
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
|
||||
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
|
||||
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
|
||||
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
|
||||
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
|
||||
|
||||
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
|
||||
OR yard_djib_port IS NULL OR yard_nagad IS NULL
|
||||
THEN
|
||||
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
|
||||
END IF;
|
||||
|
||||
yards := ARRAY[
|
||||
yard_kality,
|
||||
yard_mojo,
|
||||
yard_dire_dawa,
|
||||
yard_djib_port,
|
||||
yard_nagad
|
||||
];
|
||||
|
||||
FOR wt IN
|
||||
SELECT id, code, capacity_tons, tare_weight_tons
|
||||
FROM freight.wagon_types
|
||||
WHERE is_active = true
|
||||
ORDER BY code
|
||||
LOOP
|
||||
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
|
||||
v_payload := COALESCE(wt.capacity_tons, 60.0);
|
||||
|
||||
FOR i IN 1 .. 50 LOOP
|
||||
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
|
||||
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
|
||||
|
||||
INSERT INTO freight.wagons (
|
||||
id,
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
tare_weight,
|
||||
max_payload_weight,
|
||||
status,
|
||||
current_yard_id,
|
||||
train_id,
|
||||
sequence_number,
|
||||
notes,
|
||||
train_set_wagon_id,
|
||||
current_train_schedule_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
wagon_num,
|
||||
wt.id,
|
||||
v_tare,
|
||||
v_payload,
|
||||
'Available',
|
||||
yard_id,
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
now(), now()
|
||||
)
|
||||
ON CONFLICT (wagon_number) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Remove all seeded wagons (full wipe — mirrors what up() did)
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Day-level booking pool: customers select a DAY (route + day), not a specific
|
||||
* train. The batch engine's pool query filters bookings on
|
||||
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
|
||||
* index backs that scan.
|
||||
*/
|
||||
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
|
||||
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateWarehouseModule1790000000000 implements MigrationInterface {
|
||||
name = 'CreateWarehouseModule1790000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL UNIQUE,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
station_id UUID NULL,
|
||||
location_name VARCHAR(200) NULL,
|
||||
capacity_weight NUMERIC(14,3) NULL,
|
||||
capacity_containers INT NULL,
|
||||
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
current_containers INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_yards (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id) ON DELETE CASCADE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
capacity_weight NUMERIC(14,3) NULL,
|
||||
capacity_containers INT NULL,
|
||||
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
current_containers INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
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,
|
||||
CONSTRAINT uq_warehouse_yards_code UNIQUE (warehouse_id, code)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_zones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id) ON DELETE CASCADE,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
capacity_weight NUMERIC(14,3) NULL,
|
||||
capacity_containers INT NULL,
|
||||
current_weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
current_containers INT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
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,
|
||||
CONSTRAINT uq_warehouse_zones_code UNIQUE (yard_id, code)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
warehouse_id UUID NOT NULL REFERENCES freight.warehouses(id),
|
||||
yard_id UUID NOT NULL REFERENCES freight.warehouse_yards(id),
|
||||
zone_id UUID NOT NULL REFERENCES freight.warehouse_zones(id),
|
||||
booking_id UUID NOT NULL,
|
||||
cargo_id UUID NULL,
|
||||
container_id UUID NULL,
|
||||
goods_id UUID NULL,
|
||||
quantity NUMERIC(12,3) NOT NULL DEFAULT 0,
|
||||
weight NUMERIC(14,3) NOT NULL DEFAULT 0,
|
||||
volume NUMERIC(12,3) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE',
|
||||
inspection_status VARCHAR(20) NULL,
|
||||
arrived_at TIMESTAMPTZ NULL,
|
||||
inspected_at TIMESTAMPTZ NULL,
|
||||
ready_for_loading_at TIMESTAMPTZ NULL,
|
||||
notes TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const indexes: Array<[string, string, string]> = [
|
||||
['idx_warehouses_type', 'warehouses', 'type'],
|
||||
['idx_warehouses_status', 'warehouses', 'status'],
|
||||
['idx_warehouses_station_id', 'warehouses', 'station_id'],
|
||||
['idx_warehouse_yards_warehouse_id', 'warehouse_yards', 'warehouse_id'],
|
||||
['idx_warehouse_yards_type', 'warehouse_yards', 'type'],
|
||||
['idx_warehouse_yards_status', 'warehouse_yards', 'status'],
|
||||
['idx_warehouse_zones_yard_id', 'warehouse_zones', 'yard_id'],
|
||||
['idx_warehouse_zones_type', 'warehouse_zones', 'type'],
|
||||
['idx_warehouse_zones_status', 'warehouse_zones', 'status'],
|
||||
['idx_warehouse_inventory_warehouse_id', 'warehouse_inventory', 'warehouse_id'],
|
||||
['idx_warehouse_inventory_yard_id', 'warehouse_inventory', 'yard_id'],
|
||||
['idx_warehouse_inventory_zone_id', 'warehouse_inventory', 'zone_id'],
|
||||
['idx_warehouse_inventory_booking_id', 'warehouse_inventory', 'booking_id'],
|
||||
['idx_warehouse_inventory_cargo_id', 'warehouse_inventory', 'cargo_id'],
|
||||
['idx_warehouse_inventory_container_id', 'warehouse_inventory', 'container_id'],
|
||||
['idx_warehouse_inventory_goods_id', 'warehouse_inventory', 'goods_id'],
|
||||
['idx_warehouse_inventory_status', 'warehouse_inventory', 'status'],
|
||||
];
|
||||
|
||||
for (const [indexName, table, column] of indexes) {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${indexName} ON freight.${table}(${column});`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_zones;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_yards;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouses;`);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WarehouseBatch21790000000001 implements MigrationInterface {
|
||||
name = 'WarehouseBatch21790000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Capacity columns (weight + volume) on warehouse / yard / zone ──────
|
||||
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.${table}
|
||||
ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0;
|
||||
`);
|
||||
// Backfill max_weight from the Batch 1 capacity_weight column.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ───────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ALTER COLUMN status SET DEFAULT 'RECEIVED';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION';
|
||||
`);
|
||||
|
||||
// ── New lifecycle timestamps ──────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL;
|
||||
`);
|
||||
|
||||
// booking_id becomes nullable (inventory can exist before booking linkage).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
// ── Movement history ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
|
||||
from_warehouse_id UUID NOT NULL,
|
||||
from_yard_id UUID NOT NULL,
|
||||
from_zone_id UUID NOT NULL,
|
||||
to_warehouse_id UUID NOT NULL,
|
||||
to_yard_id UUID NOT NULL,
|
||||
to_zone_id UUID NOT NULL,
|
||||
remarks TEXT NULL,
|
||||
moved_by VARCHAR(120) NULL,
|
||||
moved_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_warehouse_inventory_movement_inventory_id
|
||||
ON freight.warehouse_inventory_movement(inventory_id);
|
||||
`);
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id UUID NULL,
|
||||
warehouse_id UUID NULL,
|
||||
activity_type VARCHAR(40) NOT NULL,
|
||||
description TEXT NULL,
|
||||
performed_by VARCHAR(120) 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_warehouse_activity_log_inventory_id
|
||||
ON freight.warehouse_activity_log(inventory_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id
|
||||
ON freight.warehouse_activity_log(warehouse_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type
|
||||
ON freight.warehouse_activity_log(activity_type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
DROP COLUMN IF EXISTS stored_at,
|
||||
DROP COLUMN IF EXISTS reserved_at,
|
||||
DROP COLUMN IF EXISTS loaded_at,
|
||||
DROP COLUMN IF EXISTS dispatched_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED';
|
||||
`);
|
||||
|
||||
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.${table}
|
||||
DROP COLUMN IF EXISTS max_weight,
|
||||
DROP COLUMN IF EXISTS max_volume,
|
||||
DROP COLUMN IF EXISTS current_volume;
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 3 — Warehouse → Loading → Train Departure visibility.
|
||||
* Adds the warehouse_loadings record (inventory ↔ wagon). Does NOT touch any
|
||||
* scheduling / wagon tables — the warehouse only reads from those.
|
||||
*/
|
||||
export class WarehouseBatch31790000000002 implements MigrationInterface {
|
||||
name = 'WarehouseBatch31790000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_loadings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
warehouse_inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
|
||||
booking_id UUID NULL,
|
||||
wagon_id UUID NOT NULL,
|
||||
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
loaded_by VARCHAR(120) NULL,
|
||||
loaded_weight NUMERIC(14,3) NULL,
|
||||
notes 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_warehouse_loadings_inventory_id
|
||||
ON freight.warehouse_loadings(warehouse_inventory_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_booking_id
|
||||
ON freight.warehouse_loadings(booking_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_wagon_id
|
||||
ON freight.warehouse_loadings(wagon_id);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_loadings;`);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
|
||||
`);
|
||||
|
||||
// Existing users already use the portal — never re-gate them behind the
|
||||
// new onboarding wizard.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.external_profiles
|
||||
SET onboarding_completed = true
|
||||
WHERE onboarding_completed = false;
|
||||
`);
|
||||
|
||||
// Backfill the active mode for existing users from their company's
|
||||
// operational profiles. Prefer importer, then exporter, then whichever
|
||||
// single profile the company has (forwarder/dj/transporter).
|
||||
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;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_completed;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS onboarding_step;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.external_profiles
|
||||
DROP COLUMN IF EXISTS active_profile_type;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
|
||||
* and demurrage lifecycle timestamps on inventory. Idempotent.
|
||||
*/
|
||||
export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_allocation_rules',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'name', type: 'varchar', length: '160' },
|
||||
{ name: 'priority', type: 'int', default: 100 },
|
||||
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'container_status', type: 'varchar', length: '24', isNullable: true },
|
||||
{ name: 'requires_inspection', type: 'boolean', isNullable: true },
|
||||
{ name: 'target_facility_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'target_yard_code', type: 'varchar', length: '40' },
|
||||
{ name: 'target_warehouse_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'target_zone_code', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'storage_type', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_war_priority', columnNames: ['priority'] },
|
||||
{ name: 'idx_war_active', columnNames: ['is_active'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_fee_rules',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'name', type: 'varchar', length: '160' },
|
||||
{ name: 'rule_type', type: 'varchar', length: '20' },
|
||||
{ name: 'priority', type: 'int', default: 100 },
|
||||
{ name: 'freight_type', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'trade_direction', type: 'varchar', length: '16', isNullable: true },
|
||||
{ name: 'cargo_type_code', type: 'varchar', length: '50', isNullable: true },
|
||||
{ name: 'container_type', type: 'varchar', length: '40', isNullable: true },
|
||||
{ name: 'facility_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'yard_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', default: 0 },
|
||||
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_wfr_type', columnNames: ['rule_type'] },
|
||||
{ name: 'idx_wfr_active', columnNames: ['is_active'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const columnsToAdd = [
|
||||
{ name: 'inspection_started_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'inspection_completed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'ready_for_pickup_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'release_date', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'gate_cleared_at', type: 'timestamptz', isNullable: true },
|
||||
];
|
||||
|
||||
const columnsToCreate = columnsToAdd.filter(
|
||||
(col) => !inventoryTable.columns.some((c) => c.name === col.name),
|
||||
);
|
||||
|
||||
if (columnsToCreate.length > 0) {
|
||||
await queryRunner.addColumns(
|
||||
'freight.warehouse_inventory',
|
||||
columnsToCreate.map((col) => new TableColumn(col)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const inventoryTable = await queryRunner.getTable('freight.warehouse_inventory');
|
||||
if (inventoryTable) {
|
||||
const columnNames = [
|
||||
'inspection_started_at',
|
||||
'inspection_completed_at',
|
||||
'ready_for_pickup_at',
|
||||
'release_date',
|
||||
'gate_cleared_at',
|
||||
];
|
||||
const columnsToRemove = columnNames.filter((name) =>
|
||||
inventoryTable.columns.some((c) => c.name === name),
|
||||
);
|
||||
|
||||
if (columnsToRemove.length > 0) {
|
||||
await queryRunner.dropColumns('freight.warehouse_inventory', columnsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
const feeRulesTable = await queryRunner.getTable('freight.warehouse_fee_rules');
|
||||
if (feeRulesTable) {
|
||||
await queryRunner.dropTable('freight.warehouse_fee_rules', true);
|
||||
}
|
||||
|
||||
const allocationRulesTable = await queryRunner.getTable('freight.warehouse_allocation_rules');
|
||||
if (allocationRulesTable) {
|
||||
await queryRunner.dropTable('freight.warehouse_allocation_rules', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCompanyProfileIdToBookings1791000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddCompanyProfileIdToBookings1791000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
|
||||
ON freight.bookings(company_profile_id);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
|
||||
) THEN
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "FK_bookings_company_profile_id"
|
||||
FOREIGN KEY (company_profile_id)
|
||||
REFERENCES freight.company_profiles(id);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
|
||||
// profile, for each booking's own company.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_profile_id IS NULL
|
||||
AND (
|
||||
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
|
||||
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
|
||||
);
|
||||
`);
|
||||
|
||||
// Forwarder / single-profile companies: one profile per company, so the
|
||||
// mapping is unambiguous regardless of trade direction.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM freight.company_profiles cp
|
||||
JOIN freight.companies c ON c.id = cp.company_id
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND c.type <> 'customer'
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
|
||||
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
|
||||
// matching profile): attribute to the company's importer profile, else its
|
||||
// exporter profile, so nothing disappears from the customer's list.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.bookings b
|
||||
SET company_profile_id = cp.id
|
||||
FROM (
|
||||
SELECT DISTINCT ON (company_id) company_id, id
|
||||
FROM freight.company_profiles
|
||||
ORDER BY company_id,
|
||||
CASE type
|
||||
WHEN 'importer' THEN 0
|
||||
WHEN 'exporter' THEN 1
|
||||
ELSE 2
|
||||
END
|
||||
) cp
|
||||
WHERE cp.company_id = b.company_id
|
||||
AND b.company_id IS NOT NULL
|
||||
AND b.company_profile_id IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS company_profile_id;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */
|
||||
export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_fee_invoices',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'invoice_number', type: 'varchar', length: '40', isUnique: true },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'inventory_id', type: 'uuid' },
|
||||
{ name: 'facility_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'warehouse_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'yard_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'invoice_type', type: 'varchar', length: '32', default: "'MIXED_WAREHOUSE_FEES'" },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'DRAFT'" },
|
||||
{ name: 'subtotal_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'tax_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'paid_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'balance_amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'period_start', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'period_end', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'issued_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'due_date', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'paid_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'cancelled_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'payments', type: 'jsonb', default: "'[]'" },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
indices: [
|
||||
{ name: 'idx_wfi_booking', columnNames: ['booking_id'] },
|
||||
{ name: 'idx_wfi_inventory', columnNames: ['inventory_id'] },
|
||||
{ name: 'idx_wfi_status', columnNames: ['status'] },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'warehouse_fee_invoice_items',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' },
|
||||
{ name: 'invoice_id', type: 'uuid' },
|
||||
{ name: 'fee_rule_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'fee_type', type: 'varchar', length: '32' },
|
||||
{ name: 'description', type: 'varchar', length: '255' },
|
||||
{ name: 'quantity', type: 'numeric', precision: 12, scale: 2, default: 1 },
|
||||
{ name: 'unit_rate', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'amount', type: 'numeric', precision: 14, scale: 2, default: 0 },
|
||||
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
|
||||
{ name: 'chargeable_days', type: 'int', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['invoice_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'warehouse_fee_invoices',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
indices: [{ name: 'idx_wfii_invoice', columnNames: ['invoice_id'] }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.warehouse_fee_invoice_items', true);
|
||||
await queryRunner.dropTable('freight.warehouse_fee_invoices', true);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Import pickup branch on warehouse_inventory:
|
||||
* - release_order_reference: DO / release order number sent to the customer
|
||||
* - delivered_at: when the goods were handed over (proof of delivery)
|
||||
*
|
||||
* Idempotent: the shared dev DB may already carry some of these columns
|
||||
* (added by another checkout), so only add what is missing.
|
||||
*/
|
||||
export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }),
|
||||
);
|
||||
}
|
||||
if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'release_order_reference')) {
|
||||
await queryRunner.dropColumn(this.table, 'release_order_reference');
|
||||
}
|
||||
if (await queryRunner.hasColumn(this.table, 'delivered_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'delivered_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddNationalityToCompanies1791000000002
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddNationalityToCompanies1791000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS nationality varchar(32);
|
||||
`);
|
||||
|
||||
// Existing companies default to Ethiopian (country defaults to Ethiopia).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.companies
|
||||
SET nationality = 'ethiopian'
|
||||
WHERE nationality IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS nationality;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
DROP COLUMN IF EXISTS business_license_files;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddETradeFieldsToCompanies1791000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddETradeFieldsToCompanies1791000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS licence_number varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS status_description text;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS date_registered varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewed_from varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewal_date varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS renewed_to varchar(50);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS region varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS zone varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS woreda varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS kebele varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS house_no varchar(100);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
ADD COLUMN IF NOT EXISTS etrade_phone varchar(20);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS licence_number;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS status_description;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS date_registered;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewed_from;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewal_date;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS renewed_to;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS region;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS zone;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS woreda;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS kebele;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS house_no;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS etrade_phone;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 8 — train-arrival unload landing state on warehouse_inventory:
|
||||
* - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection)
|
||||
*
|
||||
* The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change.
|
||||
* Idempotent: the shared dev DB may already carry this column (added by another checkout).
|
||||
*/
|
||||
export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'unloaded_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fix: migration 1750000000001 (AddFacilityIdToWarehouses) silently skipped because
|
||||
* the freight.warehouses table didn't exist yet at that timestamp. The column was
|
||||
* never added. Add it now with idempotent guards.
|
||||
*/
|
||||
export class AddFacilityIdToWarehousesFix1791000000004 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouses';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'facility_id'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({
|
||||
name: 'facility_id',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const table = await queryRunner.getTable(this.table);
|
||||
const hasFk = table?.foreignKeys.some((fk) => fk.columnNames.includes('facility_id'));
|
||||
if (!hasFk) {
|
||||
await queryRunner.createForeignKey(
|
||||
this.table,
|
||||
new TableForeignKey({
|
||||
columnNames: ['facility_id'],
|
||||
referencedColumnNames: ['id'],
|
||||
referencedTableName: 'facilities',
|
||||
referencedSchema: 'freight',
|
||||
onDelete: 'SET NULL',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable(this.table);
|
||||
if (!table) return;
|
||||
|
||||
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
|
||||
if (foreignKey) {
|
||||
await queryRunner.dropForeignKey(this.table, foreignKey);
|
||||
}
|
||||
|
||||
if (await queryRunner.hasColumn(this.table, 'facility_id')) {
|
||||
await queryRunner.dropColumn(this.table, 'facility_id');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before
|
||||
* the warehouse_inventory table exists, so it cannot add inspection_status.
|
||||
*/
|
||||
export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) {
|
||||
await queryRunner.dropColumn(this.table, 'inspection_status');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Creates the generic dropdown settings tables (freight.dropdown_settings +
|
||||
* freight.dropdown_options) backing the DropdownSetting / DropdownOption
|
||||
* entities. These tables previously only existed via `synchronize` on some
|
||||
* databases; this migration makes them part of the migration history so the
|
||||
* SeedGeneralContractPeriod migration (which inserts into them) can run on a
|
||||
* fresh database. Idempotent so it is safe on DBs where the tables already exist.
|
||||
*/
|
||||
export class CreateDropdownSettings1791999999999
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateDropdownSettings1791999999999';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."dropdown_settings" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"code" varchar(128) NOT NULL,
|
||||
"label" varchar(256) NOT NULL,
|
||||
"description" text,
|
||||
"multiple" boolean NOT NULL DEFAULT false,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "PK_dropdown_settings" PRIMARY KEY ("id")
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_settings_code"
|
||||
ON "freight"."dropdown_settings" ("code");
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "freight"."dropdown_options" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"setting_id" uuid NOT NULL,
|
||||
"value" varchar(256) NOT NULL,
|
||||
"label" varchar(256) NOT NULL,
|
||||
"note" text,
|
||||
"is_disabled" boolean NOT NULL DEFAULT false,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "PK_dropdown_options" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_dropdown_options_setting" FOREIGN KEY ("setting_id")
|
||||
REFERENCES "freight"."dropdown_settings" ("id") ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_options_setting_value"
|
||||
ON "freight"."dropdown_options" ("setting_id", "value");
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."dropdown_options";`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "freight"."dropdown_settings";`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user