mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
18
.github/workflows/deploy.yml
vendored
18
.github/workflows/deploy.yml
vendored
@@ -182,24 +182,6 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate
|
||||||
|
|
||||||
- name: Verify deployment health
|
|
||||||
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2)
|
|
||||||
echo "Waiting for service to become healthy on port ${PORT}..."
|
|
||||||
for i in $(seq 1 12); do
|
|
||||||
if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then
|
|
||||||
echo "Service is healthy."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Attempt ${i}/12 — not ready yet, waiting 10s..."
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
echo "Service failed health check after 120s — rolling back"
|
|
||||||
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Remove npm credentials from workspace
|
- name: Remove npm credentials from workspace
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f .npmrc .npmrc_temp
|
run: rm -f .npmrc .npmrc_temp
|
||||||
|
|||||||
@@ -61,4 +61,25 @@ REDIS_PORT=6379
|
|||||||
RABBITMQ_ENABLED=false
|
RABBITMQ_ENABLED=false
|
||||||
RABBITMQ_URL=amqp://localhost:5672
|
RABBITMQ_URL=amqp://localhost:5672
|
||||||
SMS_QUEUE=sms_queue
|
SMS_QUEUE=sms_queue
|
||||||
|
|
||||||
|
# ── VeriFayda 2.0 (eSignet OIDC) identity verification ──────────────────────
|
||||||
|
# Disabled by default; /fayda/verification/start returns 503 until enabled.
|
||||||
|
FAYDA_ENABLED=false
|
||||||
|
FAYDA_CLIENT_ID=
|
||||||
|
FAYDA_AUTHORIZATION_ENDPOINT=
|
||||||
|
FAYDA_TOKEN_ENDPOINT=
|
||||||
|
FAYDA_USERINFO_ENDPOINT=
|
||||||
|
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
|
||||||
|
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
|
||||||
|
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
|
||||||
|
FAYDA_CLAIMS_LOCALES=en am
|
||||||
|
FAYDA_SESSION_TTL_MINUTES=10
|
||||||
|
EXPIRATION_TIME=15
|
||||||
|
ALGORITHM=RS256
|
||||||
EMAIL_QUEUE=email_queue
|
EMAIL_QUEUE=email_queue
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||||
|
"seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts",
|
||||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"handlebars": "^4.7.9",
|
"handlebars": "^4.7.9",
|
||||||
|
"jose": "^5.10.0",
|
||||||
"libphonenumber-js": "^1.13.6",
|
"libphonenumber-js": "^1.13.6",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
import {
|
||||||
|
MiddlewareConsumer,
|
||||||
|
Module,
|
||||||
|
OnApplicationBootstrap,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
@@ -12,6 +16,7 @@ import appConfig from "./config/app.config";
|
|||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
import telebirrConfig from "./config/telebirr.config";
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
import faydaConfig from "./config/fayda.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { ContractsModule } from "./modules/contracts/contracts.module";
|
import { ContractsModule } from "./modules/contracts/contracts.module";
|
||||||
@@ -59,28 +64,32 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
|||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||||
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
|
||||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||||
|
import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder";
|
||||||
//New Trains, Wagons, Container and Cargo management modules
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { VerifaydaModule } from './modules/verifayda/verifayda.module';
|
||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||||
import { RoutesModule } from './modules/routes/routes.module';
|
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||||
import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||||
import { OverviewModule } from './modules/overview/overview.module';
|
import { RoutesModule } from "./modules/routes/routes.module";
|
||||||
import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||||
import { DriversModule } from './modules/drivers/drivers.module';
|
import { OverviewModule } from "./modules/overview/overview.module";
|
||||||
import { FuelModule } from './modules/fuel/fuel.module';
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||||
|
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||||
|
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||||
|
import { LoggerMiddleware } from "./logger.middleware";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
@@ -141,6 +150,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
LastMileModule,
|
LastMileModule,
|
||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
ImportOperationsModule,
|
ImportOperationsModule,
|
||||||
|
VerifaydaModule,
|
||||||
|
FleetHistoryModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EdrOrgSeeder,
|
EdrOrgSeeder,
|
||||||
@@ -160,6 +171,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
ExportDjiboutiInterchangeDemoSeeder,
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
MarshallingDemoTrainsSeeder,
|
MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
|
PaidImportExportMileDemoSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
@@ -211,4 +223,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// bookings bill to. Idempotent — keyed by fixed IDs.
|
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||||
await this.govCompaniesSeeder.run();
|
await this.govCompaniesSeeder.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
126
apps/edr-freight-api/src/config/fayda.config.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export interface FaydaJwk {
|
||||||
|
kty: 'RSA';
|
||||||
|
use?: string;
|
||||||
|
kid?: string;
|
||||||
|
alg?: string;
|
||||||
|
n: string;
|
||||||
|
e: string;
|
||||||
|
d: string;
|
||||||
|
p?: string;
|
||||||
|
q?: string;
|
||||||
|
dp?: string;
|
||||||
|
dq?: string;
|
||||||
|
qi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FaydaPlatform = 'WEB' | 'MOBILE';
|
||||||
|
|
||||||
|
export interface FaydaConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
clientId: string;
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
tokenEndpoint: string;
|
||||||
|
userInfoEndpoint: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for MOBILE clients. */
|
||||||
|
redirectUri: string;
|
||||||
|
/** OAuth redirect_uri sent to eSignet for WEB clients. Falls back to `redirectUri`. */
|
||||||
|
webRedirectUri: string;
|
||||||
|
privateJwk: FaydaJwk;
|
||||||
|
scope: string;
|
||||||
|
acrValues: string;
|
||||||
|
claimsLocales: string;
|
||||||
|
sessionTtlMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_VARS = [
|
||||||
|
'FAYDA_CLIENT_ID',
|
||||||
|
'FAYDA_AUTHORIZATION_ENDPOINT',
|
||||||
|
'FAYDA_TOKEN_ENDPOINT',
|
||||||
|
'FAYDA_USERINFO_ENDPOINT',
|
||||||
|
'FAYDA_PRIVATE_KEY_BASE64',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function decodePrivateJwk(base64: string): FaydaJwk {
|
||||||
|
let jwk: unknown;
|
||||||
|
try {
|
||||||
|
const json = Buffer.from(base64, 'base64').toString('utf8');
|
||||||
|
jwk = JSON.parse(json);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`FAYDA_PRIVATE_KEY_BASE64 is not valid Base64-encoded JSON: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!jwk || typeof jwk !== 'object') {
|
||||||
|
throw new Error('FAYDA_PRIVATE_KEY_BASE64 must decode to a JSON object');
|
||||||
|
}
|
||||||
|
const candidate = jwk as Partial<FaydaJwk>;
|
||||||
|
if (candidate.kty !== 'RSA') {
|
||||||
|
throw new Error('FAYDA_PRIVATE_KEY_BASE64 JWK must have kty="RSA"');
|
||||||
|
}
|
||||||
|
if (!candidate.n || !candidate.e || !candidate.d) {
|
||||||
|
throw new Error(
|
||||||
|
'FAYDA_PRIVATE_KEY_BASE64 JWK is missing required RSA private-key fields (n, e, d)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return candidate as FaydaJwk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default registerAs('fayda', (): FaydaConfig => {
|
||||||
|
const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true';
|
||||||
|
// `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address`
|
||||||
|
// are needed so the matching essential claims aren't rejected as out-of-scope.
|
||||||
|
const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address';
|
||||||
|
const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code';
|
||||||
|
const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am';
|
||||||
|
const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10);
|
||||||
|
const redirectUri = process.env.FAYDA_REDIRECT_URI ?? '';
|
||||||
|
const webRedirectUri = process.env.FAYDA_WEB_REDIRECT_URI || redirectUri;
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
clientId: process.env.FAYDA_CLIENT_ID ?? '',
|
||||||
|
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT ?? '',
|
||||||
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT ?? '',
|
||||||
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT ?? '',
|
||||||
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: Number.isNaN(sessionTtl) || sessionTtl <= 0 ? 10 : sessionTtl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = REQUIRED_VARS.filter((name) => !process.env[name]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Fayda integration is enabled (FAYDA_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!redirectUri) {
|
||||||
|
throw new Error(
|
||||||
|
'Fayda integration is enabled but the redirect URI is missing: set FAYDA_REDIRECT_URI',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Number.isNaN(sessionTtl) || sessionTtl <= 0) {
|
||||||
|
throw new Error('FAYDA_SESSION_TTL_MINUTES must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
clientId: process.env.FAYDA_CLIENT_ID!,
|
||||||
|
authorizationEndpoint: process.env.FAYDA_AUTHORIZATION_ENDPOINT!,
|
||||||
|
tokenEndpoint: process.env.FAYDA_TOKEN_ENDPOINT!,
|
||||||
|
userInfoEndpoint: process.env.FAYDA_USERINFO_ENDPOINT!,
|
||||||
|
redirectUri,
|
||||||
|
webRedirectUri,
|
||||||
|
privateJwk: decodePrivateJwk(process.env.FAYDA_PRIVATE_KEY_BASE64!),
|
||||||
|
scope,
|
||||||
|
acrValues,
|
||||||
|
claimsLocales,
|
||||||
|
sessionTtlMinutes: sessionTtl,
|
||||||
|
};
|
||||||
|
});
|
||||||
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
21
apps/edr-freight-api/src/logger.middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggerMiddleware implements NestMiddleware {
|
||||||
|
private readonly logger = new Logger("HTTP");
|
||||||
|
|
||||||
|
use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
res.on("finish", () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,8 @@ async function bootstrap() {
|
|||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix("api");
|
// /callback stays un-prefixed: it's the Fayda OAuth redirect_uri ack endpoint.
|
||||||
|
app.setGlobalPrefix("api", { exclude: ["callback"] });
|
||||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||||
// coercion turns any non-empty multipart/form-data string (including the
|
// coercion turns any non-empty multipart/form-data string (including the
|
||||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repairs schema drift on databases that were originally built by TypeORM
|
||||||
|
* `synchronize` (at an older entity snapshot) and never had their migration
|
||||||
|
* history recorded. Such databases have `freight.migrations` empty while most
|
||||||
|
* of the schema already exists, so a from-scratch migration run aborts on the
|
||||||
|
* first non-idempotent statement and never reaches the columns/tables added
|
||||||
|
* after synchronize was last used.
|
||||||
|
*
|
||||||
|
* The deployment procedure for those databases is:
|
||||||
|
* 1. Baseline every pre-existing migration into `freight.migrations`.
|
||||||
|
* 2. Run migrations — this file is the only pending one and back-fills the
|
||||||
|
* objects the drift scan found missing.
|
||||||
|
*
|
||||||
|
* Every statement is idempotent (IF NOT EXISTS / guarded CREATE TYPE), so it is
|
||||||
|
* also safe on a clean database where the earlier migrations already created
|
||||||
|
* these objects — it simply no-ops.
|
||||||
|
*/
|
||||||
|
export class RepairSynchronizeDrift1870000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'RepairSynchronizeDrift1870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// --- enum types (derived from entities that never had a source migration) ---
|
||||||
|
await queryRunner.query(`DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.consignments_cargo_type_enum AS ENUM (
|
||||||
|
'CONTAINER', 'BULK_LIQUID', 'BULK_DRY', 'GENERAL', 'REFRIGERATED', 'HAZARDOUS'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||||
|
await queryRunner.query(`DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.consignments_status_enum AS ENUM (
|
||||||
|
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||||
|
await queryRunner.query(`DO $$ BEGIN
|
||||||
|
CREATE TYPE freight.tracking_events_status_enum AS ENUM (
|
||||||
|
'PENDING', 'LOADED', 'IN_TRANSIT', 'AT_DESTINATION', 'DELIVERED', 'RETURNED'
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null; END $$;`);
|
||||||
|
|
||||||
|
// --- missing tables ---
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.consignments (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL,
|
||||||
|
tracking_number varchar(64) NOT NULL,
|
||||||
|
cargo_type freight.consignments_cargo_type_enum NOT NULL,
|
||||||
|
weight_kg numeric(12, 2) NOT NULL,
|
||||||
|
status freight.consignments_status_enum NOT NULL DEFAULT 'PENDING',
|
||||||
|
origin_station varchar(128) NOT NULL,
|
||||||
|
destination_station varchar(128) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_consignments PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_consignments_tracking_number UNIQUE (tracking_number)
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.tracking_events (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
consignment_id uuid NOT NULL,
|
||||||
|
location varchar(256) NOT NULL,
|
||||||
|
status freight.tracking_events_status_enum NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
description text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_tracking_events PRIMARY KEY (id)
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_purchases (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
purchase_date timestamptz NOT NULL,
|
||||||
|
liters numeric(10, 2) NOT NULL,
|
||||||
|
cost_per_liter numeric(10, 2) NOT NULL,
|
||||||
|
total_cost numeric(14, 2) NOT NULL,
|
||||||
|
fuel_station varchar(255) NULL,
|
||||||
|
payment_method varchar(50) DEFAULT 'CASH',
|
||||||
|
odometer_reading numeric(10, 2) NULL,
|
||||||
|
driver_id uuid NULL,
|
||||||
|
receipt_number varchar(255) NULL,
|
||||||
|
notes text NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL,
|
||||||
|
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
|
||||||
|
REFERENCES freight.vehicles (id) ON DELETE CASCADE
|
||||||
|
);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.fuel_consumption (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
month date NOT NULL,
|
||||||
|
total_liters numeric(10, 2) NOT NULL,
|
||||||
|
total_cost numeric(14, 2) NOT NULL,
|
||||||
|
total_distance_km numeric(10, 2) NOT NULL,
|
||||||
|
fuel_efficiency_km_per_l numeric(10, 2) NULL,
|
||||||
|
number_of_purchases integer DEFAULT 0,
|
||||||
|
average_cost_per_liter numeric(10, 2) NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL,
|
||||||
|
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
|
||||||
|
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
|
||||||
|
);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_schedules (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
maintenance_type varchar NOT NULL,
|
||||||
|
description varchar NOT NULL,
|
||||||
|
scheduled_date timestamptz NOT NULL,
|
||||||
|
completed_date timestamptz,
|
||||||
|
estimated_cost numeric(14,2),
|
||||||
|
actual_cost numeric(14,2),
|
||||||
|
status varchar NOT NULL DEFAULT 'SCHEDULED',
|
||||||
|
odometer_reading numeric,
|
||||||
|
service_provider varchar,
|
||||||
|
notes text,
|
||||||
|
next_due_km numeric,
|
||||||
|
next_due_date timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_schedules_vehicle_date ON freight.maintenance_schedules (vehicle_id, scheduled_date);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.maintenance_costs (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
vehicle_id uuid NOT NULL,
|
||||||
|
maintenance_schedule_id uuid,
|
||||||
|
incurred_date timestamptz NOT NULL,
|
||||||
|
cost_amount numeric(14,2) NOT NULL,
|
||||||
|
cost_type varchar NOT NULL,
|
||||||
|
description varchar NOT NULL,
|
||||||
|
service_provider varchar,
|
||||||
|
invoice_number varchar,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_maintenance_schedule FOREIGN KEY (maintenance_schedule_id)
|
||||||
|
REFERENCES freight.maintenance_schedules (id) ON DELETE SET NULL
|
||||||
|
);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_maintenance_costs_vehicle_date ON freight.maintenance_costs (vehicle_id, incurred_date);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.otp_verifications (
|
||||||
|
id uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
phone varchar NOT NULL,
|
||||||
|
otp varchar NOT NULL,
|
||||||
|
verified boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_otp_verifications PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_otp_verifications_phone UNIQUE (phone)
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE TABLE IF NOT EXISTS freight.booking_batch_offers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE,
|
||||||
|
offered_wagons integer NOT NULL,
|
||||||
|
total_wagons integer NOT NULL,
|
||||||
|
offered_lines jsonb NULL,
|
||||||
|
offered_weight_tons numeric(12, 3) NOT NULL,
|
||||||
|
offered_amount numeric(14, 2) NOT NULL,
|
||||||
|
offered_pricing_breakdown jsonb NULL,
|
||||||
|
invoice_id uuid NULL,
|
||||||
|
payment_deadline timestamptz NOT NULL,
|
||||||
|
status varchar(10) NOT NULL DEFAULT 'OFFERED',
|
||||||
|
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_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`);
|
||||||
|
|
||||||
|
// --- missing columns on existing tables ---
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.invoices
|
||||||
|
ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS paid_at timestamptz;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS booking_type varchar(20) NOT NULL DEFAULT 'ONE_TIME',
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS bulk_reefer_quantity numeric(12,3) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS clearance_current_phase varchar(40),
|
||||||
|
ADD COLUMN IF NOT EXISTS duty_required boolean,
|
||||||
|
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
|
||||||
|
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.cargoes
|
||||||
|
ADD COLUMN IF NOT EXISTS receiver_name varchar,
|
||||||
|
ADD COLUMN IF NOT EXISTS delivered_at timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS delivery_remarks text;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.contract_clearance_cycles
|
||||||
|
ADD COLUMN IF NOT EXISTS duty_required boolean,
|
||||||
|
ADD COLUMN IF NOT EXISTS vessel_departure_date date,
|
||||||
|
ADD COLUMN IF NOT EXISTS ro_amendment_requested_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS ro_hold_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS current_phase varchar(40),
|
||||||
|
ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at timestamptz;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.first_mile
|
||||||
|
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.last_mile
|
||||||
|
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.route_milestones
|
||||||
|
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.routes
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE';`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status);`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS window_phase varchar(20) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS window_opens_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS window_closes_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS doc_review_ends_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS doc_review_completed_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS payment_phase_ends_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS booking_cycle_no integer NOT NULL DEFAULT 0;`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_train_schedules_window_phase
|
||||||
|
ON freight.train_schedules (window_phase) WHERE window_phase IS NOT NULL;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS import_window_lead_days integer NOT NULL DEFAULT 3,
|
||||||
|
ADD COLUMN IF NOT EXISTS export_booking_lead_hours integer NOT NULL DEFAULT 24,
|
||||||
|
ADD COLUMN IF NOT EXISTS window_open_hour integer NOT NULL DEFAULT 8,
|
||||||
|
ADD COLUMN IF NOT EXISTS window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3,
|
||||||
|
ADD COLUMN IF NOT EXISTS doc_review_minutes integer NOT NULL DEFAULT 30,
|
||||||
|
ADD COLUMN IF NOT EXISTS payment_window_minutes integer NOT NULL DEFAULT 60,
|
||||||
|
ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// No-op: this migration only repairs drift by additively creating objects
|
||||||
|
// that other migrations own. Rolling it back would drop objects those
|
||||||
|
// migrations legitimately created. Revert individual feature migrations
|
||||||
|
// instead if needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session store for the VeriFayda 2.0 OIDC verification flow (ported from
|
||||||
|
* passenger-api). One row per started verification; `state` is the
|
||||||
|
* single-use CSRF token linking the eSignet redirect back to the session.
|
||||||
|
*/
|
||||||
|
export class AddFaydaVerificationSessions1890000000002 implements MigrationInterface {
|
||||||
|
name = "AddFaydaVerificationSessions1890000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.fayda_verification_sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
state varchar NOT NULL UNIQUE,
|
||||||
|
code_verifier varchar NOT NULL,
|
||||||
|
purpose varchar NOT NULL DEFAULT 'VERIFY',
|
||||||
|
platform varchar NOT NULL DEFAULT 'WEB',
|
||||||
|
save_to_account boolean NOT NULL DEFAULT false,
|
||||||
|
status varchar NOT NULL DEFAULT 'PENDING',
|
||||||
|
error_code varchar,
|
||||||
|
error_description text,
|
||||||
|
iam_user_id uuid,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
completed_at timestamptz,
|
||||||
|
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_FAYDA_SESSIONS_EXPIRES_AT"
|
||||||
|
ON freight.fayda_verification_sessions (expires_at)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FAYDA_SESSIONS_IAM_USER_ID"
|
||||||
|
ON freight.fayda_verification_sessions (iam_user_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.fayda_verification_sessions`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track Fayda identity verification on drivers: whether the driver's
|
||||||
|
* identity was verified through VeriFayda and the OIDC subject it was
|
||||||
|
* verified against.
|
||||||
|
*/
|
||||||
|
export class AddDriverFaydaVerification1890000000003 implements MigrationInterface {
|
||||||
|
name = "AddDriverFaydaVerification1890000000003";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD COLUMN IF NOT EXISTS fayda_verified boolean DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS fayda_sub varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP COLUMN IF EXISTS fayda_verified,
|
||||||
|
DROP COLUMN IF EXISTS fayda_sub
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow more than one vehicle per last-mile delivery. Junction table joins
|
||||||
|
* last_mile ⇄ vehicles; existing single vehicle_id values are backfilled as
|
||||||
|
* the first assignment so nothing is lost.
|
||||||
|
*/
|
||||||
|
export class AddLastMileVehicleAssignments1890000000004 implements MigrationInterface {
|
||||||
|
name = "AddLastMileVehicleAssignments1890000000004";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
last_mile_id uuid NOT NULL REFERENCES freight.last_mile(id) ON DELETE CASCADE,
|
||||||
|
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT "UQ_LAST_MILE_VEHICLE" UNIQUE (last_mile_id, vehicle_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_LM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||||
|
ON freight.last_mile_vehicle_assignments (vehicle_id)
|
||||||
|
`);
|
||||||
|
// Backfill: existing single-vehicle assignments become the first row
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.last_mile_vehicle_assignments (last_mile_id, vehicle_id)
|
||||||
|
SELECT id, vehicle_id FROM freight.last_mile
|
||||||
|
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||||
|
ON CONFLICT (last_mile_id, vehicle_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the driver's gender. Prefilled from the Fayda VERIFY response
|
||||||
|
* (Male/Female) but editable; nullable so existing rows and manual,
|
||||||
|
* non-Fayda driver records stay valid.
|
||||||
|
*/
|
||||||
|
export class AddDriverGender1890000000005 implements MigrationInterface {
|
||||||
|
name = "AddDriverGender1890000000005";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD COLUMN IF NOT EXISTS gender varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP COLUMN IF EXISTS gender
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce one driver record per verified Fayda identity. A unique index on
|
||||||
|
* fayda_sub blocks a second driver from being created against the same Fayda
|
||||||
|
* OIDC subject; NULLs stay distinct so legacy/unverified rows are unaffected.
|
||||||
|
*/
|
||||||
|
export class AddDriverFaydaSubUnique1890000000006 implements MigrationInterface {
|
||||||
|
name = "AddDriverFaydaSubUnique1890000000006";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
ON freight.drivers (fayda_sub)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make driver uniqueness soft-delete aware. The original table used plain
|
||||||
|
* column UNIQUE constraints (drivers_email_key, etc.) which count soft-deleted
|
||||||
|
* rows, so deleting a driver then re-adding the same email/phone/license/Fayda
|
||||||
|
* identity failed at the DB with a raw 500 — even though the service's own
|
||||||
|
* (deleted_at-excluding) duplicate check saw nothing. Replace them with partial
|
||||||
|
* unique indexes scoped to live rows (deleted_at IS NULL) so uniqueness matches
|
||||||
|
* what the service enforces and freed values become reusable after deletion.
|
||||||
|
*/
|
||||||
|
export class DriverUniquePartialSoftDelete1890000000007 implements MigrationInterface {
|
||||||
|
name = "DriverUniquePartialSoftDelete1890000000007";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop the full-table unique constraints from CreateDriversTable...
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_email_key,
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_phone_number_key,
|
||||||
|
DROP CONSTRAINT IF EXISTS drivers_license_number_key
|
||||||
|
`);
|
||||||
|
// ...and the plain fayda_sub unique index from 1890000000006.
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB"`);
|
||||||
|
|
||||||
|
// Re-add each as a partial unique index scoped to non-deleted rows.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_EMAIL_ACTIVE"
|
||||||
|
ON freight.drivers (email) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_PHONE_ACTIVE"
|
||||||
|
ON freight.drivers (phone_number) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_LICENSE_ACTIVE"
|
||||||
|
ON freight.drivers (license_number) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB_ACTIVE"
|
||||||
|
ON freight.drivers (fayda_sub) WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_EMAIL_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_PHONE_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_LICENSE_ACTIVE"`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_DRIVERS_FAYDA_SUB_ACTIVE"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_DRIVERS_FAYDA_SUB"
|
||||||
|
ON freight.drivers (fayda_sub)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.drivers
|
||||||
|
ADD CONSTRAINT drivers_email_key UNIQUE (email),
|
||||||
|
ADD CONSTRAINT drivers_phone_number_key UNIQUE (phone_number),
|
||||||
|
ADD CONSTRAINT drivers_license_number_key UNIQUE (license_number)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log for fleet activity (driver↔vehicle assignments, vehicle
|
||||||
|
* status/availability transitions, first/last-mile vehicle assignments + mile
|
||||||
|
* status changes). Queried by vehicle_id or driver_id to build a per-record
|
||||||
|
* timeline. Populated going forward — existing records have no back-history.
|
||||||
|
*/
|
||||||
|
export class AddFleetEvents1890000000008 implements MigrationInterface {
|
||||||
|
name = "AddFleetEvents1890000000008";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.fleet_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_type varchar NOT NULL,
|
||||||
|
vehicle_id uuid,
|
||||||
|
driver_id uuid,
|
||||||
|
first_mile_id uuid,
|
||||||
|
last_mile_id uuid,
|
||||||
|
from_value varchar,
|
||||||
|
to_value varchar,
|
||||||
|
label varchar,
|
||||||
|
metadata jsonb,
|
||||||
|
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_FLEET_EVENTS_VEHICLE"
|
||||||
|
ON freight.fleet_events (vehicle_id, created_at)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FLEET_EVENTS_DRIVER"
|
||||||
|
ON freight.fleet_events (driver_id, created_at)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.fleet_events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container number carried by each vehicle on a last-mile delivery. Auto-filled
|
||||||
|
* from the booking's container number when present, else entered by the operator
|
||||||
|
* at assignment time.
|
||||||
|
*/
|
||||||
|
export class AddLastMileAssignmentContainerNumber1890000000009
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLastMileAssignmentContainerNumber1890000000009";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS container_number varchar
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS container_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-vehicle actual distance on a last-mile delivery. A booking served by
|
||||||
|
* several trucks records each truck's km; the record's total (last_mile.exact_km)
|
||||||
|
* is their sum and drives the invoice.
|
||||||
|
*/
|
||||||
|
export class AddLastMileAssignmentDistance1890000000010
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLastMileAssignmentDistance1890000000010";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS distance_km
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings.
|
||||||
|
* Tracking only — does not gate dispatch.
|
||||||
|
*/
|
||||||
|
export class AddLoadingStatusToTrainScheduleBookings1900000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLoadingStatusToTrainScheduleBookings1900000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedule_bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedule_bookings
|
||||||
|
DROP COLUMN IF EXISTS loading_status
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplify the rate + weight-limit configuration model:
|
||||||
|
*
|
||||||
|
* 1. Drop the effective_from / effective_to validity window from both
|
||||||
|
* `rates` and `weight_limit_rules`. Rates are now activated purely by
|
||||||
|
* the approval workflow (status = LIVE) and weight limits are always
|
||||||
|
* active for their container + direction. No time-travel scheduling.
|
||||||
|
*
|
||||||
|
* 2. Enforce "one rate per pattern" with partial unique indexes so the same
|
||||||
|
* configuration (e.g. FIRST_MILE for a given container type) cannot be
|
||||||
|
* duplicated. NULL scope columns are COALESCE-normalised because Postgres
|
||||||
|
* treats NULLs as distinct in a plain unique index.
|
||||||
|
*
|
||||||
|
* This migration is destructive on the date columns — existing effective_*
|
||||||
|
* values are dropped.
|
||||||
|
*/
|
||||||
|
export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface {
|
||||||
|
name = 'SimplifyRatesAndWeightLimitRules1900000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── 1. De-duplicate existing data so the unique indexes can be created ──
|
||||||
|
// Keep the most recently-created row per pattern, soft-delete the rest.
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
rate_unit
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM freight.rates
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'
|
||||||
|
)
|
||||||
|
UPDATE freight.rates r
|
||||||
|
SET deleted_at = now()
|
||||||
|
FROM ranked
|
||||||
|
WHERE r.id = ranked.id AND ranked.rn > 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY container_type_id, trade_direction
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM freight.weight_limit_rules
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
)
|
||||||
|
UPDATE freight.weight_limit_rules w
|
||||||
|
SET deleted_at = now()
|
||||||
|
FROM ranked
|
||||||
|
WHERE w.id = ranked.id AND ranked.rn > 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── 2. Drop the effective-date indexes + columns ───────────────────────
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`);
|
||||||
|
// Indexes created by TypeORM's @Index carry generated hashed names — drop
|
||||||
|
// any index that references the effective_from column defensively.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
DECLARE idx record;
|
||||||
|
BEGIN
|
||||||
|
FOR idx IN
|
||||||
|
SELECT indexname FROM pg_indexes
|
||||||
|
WHERE schemaname = 'freight'
|
||||||
|
AND tablename IN ('rates', 'weight_limit_rules')
|
||||||
|
AND indexdef ILIKE '%effective_from%'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`);
|
||||||
|
|
||||||
|
// ── 3. One-rate-per-pattern partial unique indexes ─────────────────────
|
||||||
|
// The unit is part of the identity so a surcharge can legitimately carry two
|
||||||
|
// rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON),
|
||||||
|
// while still blocking a true duplicate (same rateType + scope + unit).
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern"
|
||||||
|
ON freight.rates (
|
||||||
|
rate_type,
|
||||||
|
COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||||
|
COALESCE(trade_direction, ''),
|
||||||
|
rate_unit
|
||||||
|
)
|
||||||
|
WHERE deleted_at IS NULL AND status <> 'SUPERSEDED';
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern"
|
||||||
|
ON freight.weight_limit_rules (container_type_id, trade_direction)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||||
|
await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widen train_scheduling_global_rules.window_duration_hours from numeric(4,2)
|
||||||
|
* to numeric(6,4). The UI now lets staff enter the booking-window duration in
|
||||||
|
* minutes / hours / days and converts to the column's native hours unit; a
|
||||||
|
* 4-minute window is 0.0667h, which numeric(4,2) rounds to 0.07 (≈3.96 min).
|
||||||
|
* Four decimals store sub-minute durations exactly (0.0667h → 4.00 min).
|
||||||
|
*/
|
||||||
|
export class WidenWindowDurationHoursPrecision1910000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "WidenWindowDurationHoursPrecision1910000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
ALTER COLUMN window_duration_hours TYPE numeric(6, 4);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_scheduling_global_rules
|
||||||
|
ALTER COLUMN window_duration_hours TYPE numeric(4, 2);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the booking-window rule onto each train schedule.
|
||||||
|
*
|
||||||
|
* A schedule's window (open time + reopen cycles) must be frozen to the rule it
|
||||||
|
* was created with: a later global-rules edit applies only to FUTURE schedules,
|
||||||
|
* while an already-open schedule keeps its base rule. Previously the batch board
|
||||||
|
* recomputed windows from the LIVE global config, so editing the rule redrew the
|
||||||
|
* board for open schedules (a synthetic grid that no longer matched the window
|
||||||
|
* the customer was shown). These columns give the board a per-schedule rule to
|
||||||
|
* derive its display windows from.
|
||||||
|
*
|
||||||
|
* Existing rows are backfilled from the current global-rules singleton — the best
|
||||||
|
* available base, since they never stored one. Their stamped windowOpensAt/
|
||||||
|
* windowClosesAt are still real, so only projected reopen cycles rely on the
|
||||||
|
* backfill.
|
||||||
|
*/
|
||||||
|
export class AddScheduleWindowRuleSnapshot1920000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddScheduleWindowRuleSnapshot1920000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_window_open_hour integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_window_duration_hours numeric(6, 4),
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_reopen_delay_minutes integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_import_window_lead_days integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_export_booking_lead_hours integer;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill from the global-rules singleton so pre-existing schedules render.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_schedules ts
|
||||||
|
SET
|
||||||
|
rule_window_open_hour = COALESCE(ts.rule_window_open_hour, r.window_open_hour),
|
||||||
|
rule_window_duration_hours = COALESCE(ts.rule_window_duration_hours, r.window_duration_hours),
|
||||||
|
rule_reopen_delay_minutes = COALESCE(ts.rule_reopen_delay_minutes, r.reopen_delay_minutes),
|
||||||
|
rule_import_window_lead_days = COALESCE(ts.rule_import_window_lead_days, r.import_window_lead_days),
|
||||||
|
rule_export_booking_lead_hours = COALESCE(ts.rule_export_booking_lead_hours, r.export_booking_lead_hours)
|
||||||
|
FROM freight.train_scheduling_global_rules r
|
||||||
|
WHERE ts.rule_window_open_hour IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
DROP COLUMN IF EXISTS rule_window_open_hour,
|
||||||
|
DROP COLUMN IF EXISTS rule_window_duration_hours,
|
||||||
|
DROP COLUMN IF EXISTS rule_reopen_delay_minutes,
|
||||||
|
DROP COLUMN IF EXISTS rule_import_window_lead_days,
|
||||||
|
DROP COLUMN IF EXISTS rule_export_booking_lead_hours;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a hard per-unit weight ceiling to weight limit rules.
|
||||||
|
*
|
||||||
|
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
|
||||||
|
* max_capacity_tons is the absolute ceiling above which a booking cannot be
|
||||||
|
* created at all. Null means no ceiling (existing behavior).
|
||||||
|
*/
|
||||||
|
export class AddMaxCapacityToWeightLimitRules1930000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddMaxCapacityToWeightLimitRules1930000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.weight_limit_rules
|
||||||
|
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.weight_limit_rules
|
||||||
|
DROP COLUMN IF EXISTS max_capacity_tons;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow more than one vehicle per first-mile pickup. Junction table joins
|
||||||
|
* first_mile ⇄ vehicles, with each truck's container number + actual distance;
|
||||||
|
* existing single vehicle_id values are backfilled as the first assignment so
|
||||||
|
* nothing is lost. Mirrors the last-mile vehicle-assignment schema.
|
||||||
|
*/
|
||||||
|
export class AddFirstMileVehicleAssignments1940000000000 implements MigrationInterface {
|
||||||
|
name = "AddFirstMileVehicleAssignments1940000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.first_mile_vehicle_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_mile_id uuid NOT NULL REFERENCES freight.first_mile(id) ON DELETE CASCADE,
|
||||||
|
vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id),
|
||||||
|
container_number varchar,
|
||||||
|
distance_km numeric(10,2),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT "UQ_FIRST_MILE_VEHICLE" UNIQUE (first_mile_id, vehicle_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_FM_VEHICLE_ASSIGNMENTS_VEHICLE"
|
||||||
|
ON freight.first_mile_vehicle_assignments (vehicle_id)
|
||||||
|
`);
|
||||||
|
// Backfill: existing single-vehicle assignments become the first row
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.first_mile_vehicle_assignments (first_mile_id, vehicle_id)
|
||||||
|
SELECT id, vehicle_id FROM freight.first_mile
|
||||||
|
WHERE vehicle_id IS NOT NULL AND deleted_at IS NULL
|
||||||
|
ON CONFLICT (first_mile_id, vehicle_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.first_mile_vehicle_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace load-type string matching with a real wagon-type foreign key.
|
||||||
|
*
|
||||||
|
* Before this migration, train scheduling picked a wagon type by matching
|
||||||
|
* strings — a hardcoded cargo-code → wagon-code map for bulk (COFFEE→KW2, …)
|
||||||
|
* and a fixed NW5 default for every container. This adds `wagon_type_id` FKs on
|
||||||
|
* `cargo_types` and `container_types` so scheduling resolves the wagon type
|
||||||
|
* through the relation instead.
|
||||||
|
*
|
||||||
|
* The columns are NULLABLE: cargo grouping rows and container/legacy cargo that
|
||||||
|
* never ship in bulk have no wagon type, and forcing one onto them is
|
||||||
|
* meaningless. Scheduling enforces the requirement at run time (it throws when a
|
||||||
|
* scheduled bulk cargo type or a container type in the batch has no wagon type).
|
||||||
|
*
|
||||||
|
* Backfill reproduces the old hardcoded resolution one final time so existing
|
||||||
|
* bulk cargo + container rows are not left unset. After this, the runtime map is
|
||||||
|
* removed — the FK is the single source of truth.
|
||||||
|
*/
|
||||||
|
export class AddWagonTypeFkToCargoAndContainerTypes1940000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddWagonTypeFkToCargoAndContainerTypes1940000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// ── Columns + FKs ────────────────────────────────────────────────────────
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_type_id uuid;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
ADD CONSTRAINT fk_cargo_types_wagon_type
|
||||||
|
FOREIGN KEY (wagon_type_id)
|
||||||
|
REFERENCES freight.wagon_types(id)
|
||||||
|
ON DELETE RESTRICT;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
ADD CONSTRAINT fk_container_types_wagon_type
|
||||||
|
FOREIGN KEY (wagon_type_id)
|
||||||
|
REFERENCES freight.wagon_types(id)
|
||||||
|
ON DELETE RESTRICT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cargo_types_wagon_type_id
|
||||||
|
ON freight.cargo_types (wagon_type_id);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_container_types_wagon_type_id
|
||||||
|
ON freight.container_types (wagon_type_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Backfill: old cargo-code → wagon-code map (one last time) ─────────────
|
||||||
|
// COFFEE/GRAIN/WHEAT/SORGHUM/CORN → KW2, FERTILIZER/SUGAR → PW2,
|
||||||
|
// COAL → KW3, STEEL/ORE → CW3. Unmapped bulk cargo → CW3 (old default).
|
||||||
|
const cargoCodeToWagon: Record<string, string> = {
|
||||||
|
COFFEE: "KW2",
|
||||||
|
GRAIN: "KW2",
|
||||||
|
WHEAT: "KW2",
|
||||||
|
SORGHUM: "KW2",
|
||||||
|
CORN: "KW2",
|
||||||
|
FERTILIZER: "PW2",
|
||||||
|
SUGAR: "PW2",
|
||||||
|
COAL: "KW3",
|
||||||
|
STEEL: "CW3",
|
||||||
|
ORE: "CW3",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [cargoCode, wagonCode] of Object.entries(cargoCodeToWagon)) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
UPDATE freight.cargo_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = $1
|
||||||
|
AND UPPER(TRIM(ct.code)) = $2
|
||||||
|
AND ct.wagon_type_id IS NULL;
|
||||||
|
`,
|
||||||
|
[wagonCode, cargoCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining bulk cargo (PER_TON) without a mapped code → default bulk wagon CW3.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.cargo_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = 'CW3'
|
||||||
|
AND ct.wagon_type_id IS NULL
|
||||||
|
AND ct.unit_of_measure = 'PER_TON';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// All container types → the old container default wagon NW5.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.container_types ct
|
||||||
|
SET wagon_type_id = wt.id
|
||||||
|
FROM freight.wagon_types wt
|
||||||
|
WHERE wt.code = 'NW5'
|
||||||
|
AND ct.wagon_type_id IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_container_types_wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_cargo_types_wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_container_types_wagon_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_cargo_types_wagon_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck customer (self-haul) assignment. Replaces the single
|
||||||
|
* booking.customer_truck_* fields with a per-booking list of trucks, each
|
||||||
|
* carrying 1–2 containers and tracking its own arrival. The legacy
|
||||||
|
* booking.customer_truck_* columns are kept as a synced booking-level flag
|
||||||
|
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
|
||||||
|
* delivery-approval logic keep working.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCustomerTruckAssignments1950000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
plate_number varchar(32) NOT NULL,
|
||||||
|
driver_name varchar(120) NOT NULL,
|
||||||
|
truck_type varchar(60) NOT NULL,
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
arrived_at timestamptz,
|
||||||
|
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_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
container_number varchar(64) 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_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
|
||||||
|
);
|
||||||
|
// One container number can be loaded onto exactly one truck per booking.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
|
||||||
|
ON freight.customer_truck_containers (booking_id, container_number)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container receive tracking. A booking's containers arrive individually
|
||||||
|
* (on separate self-haul trucks), so each container unit tracks whether it has
|
||||||
|
* been received into the port and, once staff confirm it, the GRN it belongs to.
|
||||||
|
* A single GRN covers the containers received together — so if the whole booking
|
||||||
|
* arrives at once, all its units share one GRN (per-booking GRN).
|
||||||
|
*/
|
||||||
|
export class AddContainerReceiptToBookingContainerUnits1960000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container_units
|
||||||
|
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS received_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container_units
|
||||||
|
DROP COLUMN IF EXISTS received_to_port,
|
||||||
|
DROP COLUMN IF EXISTS received_at,
|
||||||
|
DROP COLUMN IF EXISTS grn_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import self-haul trucks are weighed on leaving. The customer does not
|
||||||
|
* pre-specify what an import truck takes — staff register the containers loaded
|
||||||
|
* and the weighed gross when the truck departs. These columns capture that.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCustomerTruckDeparture1970000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
|
||||||
|
ADD COLUMN IF NOT EXISTS departed_at timestamptz
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
DROP COLUMN IF EXISTS gross_weight_kg,
|
||||||
|
DROP COLUMN IF EXISTS departed_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Controller, Get, Query } from "@nestjs/common";
|
||||||
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
|
||||||
|
import { CheckAvailabilityService } from "./check-availability.service";
|
||||||
|
|
||||||
|
@ApiTags("auth")
|
||||||
|
@Controller("auth")
|
||||||
|
@Public()
|
||||||
|
export class CheckAvailabilityController {
|
||||||
|
constructor(
|
||||||
|
private readonly checkAvailabilityService: CheckAvailabilityService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get("check-availability")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Check whether an email and/or phone number is already registered",
|
||||||
|
})
|
||||||
|
check(@Query("email") email?: string, @Query("phone") phone?: string) {
|
||||||
|
return this.checkAvailabilityService.check({ email, phone });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
|
export interface CheckAvailabilityQuery {
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckAvailabilityResult {
|
||||||
|
emailTaken: boolean;
|
||||||
|
phoneTaken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CheckAvailabilityService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check({
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
|
||||||
|
if (!email && !phone) {
|
||||||
|
throw new BadRequestException("email or phone is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await this.userRepository.find({
|
||||||
|
where: [
|
||||||
|
...(email ? [{ email }] : []),
|
||||||
|
...(phone ? [{ phoneNumber: phone }] : []),
|
||||||
|
],
|
||||||
|
select: { id: true, email: true, phoneNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailTaken: email ? matches.some((user) => user.email === email) : false,
|
||||||
|
phoneTaken: phone
|
||||||
|
? matches.some((user) => user.phoneNumber === phone)
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
||||||
|
|
||||||
|
import { CheckAvailabilityController } from './check-availability.controller';
|
||||||
|
import { CheckAvailabilityService } from './check-availability.service';
|
||||||
import { FreightMeController } from './freight-me.controller';
|
import { FreightMeController } from './freight-me.controller';
|
||||||
import { FreightMeService } from './freight-me.service';
|
import { FreightMeService } from './freight-me.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [FreightMeController],
|
imports: [TypeOrmModule.forFeature([User])],
|
||||||
providers: [FreightMeService],
|
controllers: [FreightMeController, CheckAvailabilityController],
|
||||||
|
providers: [FreightMeService, CheckAvailabilityService],
|
||||||
})
|
})
|
||||||
export class FreightAuthModule {}
|
export class FreightAuthModule {}
|
||||||
|
|||||||
@@ -307,6 +307,16 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Invoices for a batch of source records (e.g. many last-mile legs), so a
|
||||||
|
* list can show which records already have an invoice without N+1 queries. */
|
||||||
|
findBySourceIds(source: string, sourceIds: string[]): Promise<Invoice[]> {
|
||||||
|
if (!sourceIds.length) return Promise.resolve([]);
|
||||||
|
return this.invoices.findAll({
|
||||||
|
where: { source, sourceId: In(sourceIds) },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
async findForUser(
|
async findForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -947,12 +957,27 @@ export class BillingService {
|
|||||||
returnUrl: opts.returnUrl,
|
returnUrl: opts.returnUrl,
|
||||||
failureUrl: opts.failureUrl,
|
failureUrl: opts.failureUrl,
|
||||||
});
|
});
|
||||||
|
//
|
||||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
|
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||||
|
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||||
|
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||||
|
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||||
|
if (!result.immediateSuccess) {
|
||||||
|
await this.payment.handlePaymentEvent({
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
eventId: `demo-${result.intentId}`,
|
||||||
|
referenceId: invoice.sourceId,
|
||||||
|
intentId: result.intentId,
|
||||||
|
providerTxnId: result.providerTxnId,
|
||||||
|
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
result.intentId,
|
result.intentId,
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
const lines: PriceLineItemDto[] = [];
|
const lines: PriceLineItemDto[] = [];
|
||||||
const usedRatesMap = new Map<string, Rate>();
|
const usedRatesMap = new Map<string, Rate>();
|
||||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
for (const container of evalInput.containers) {
|
||||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||||
@@ -571,6 +571,23 @@ export class BookingPricingService {
|
|||||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||||||
|
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||||||
|
* on its in-memory container lines — same math, no DB row needed.
|
||||||
|
*/
|
||||||
|
private async resolveWagonCount(booking: Booking): Promise<number> {
|
||||||
|
if (!booking.id) {
|
||||||
|
return Math.ceil(
|
||||||
|
(booking.bookingContainers ?? []).reduce(
|
||||||
|
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1051,17 +1051,27 @@ export class BookingTransitionService {
|
|||||||
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||||
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||||
const fresh = await this.bookingsService.findById(booking.id);
|
const fresh = await this.bookingsService.findById(booking.id);
|
||||||
await this.bookingBatchService.acceptExportBooking(fresh);
|
try {
|
||||||
} else if (booking.tradeDirection === "IMPORT") {
|
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
} catch (err) {
|
||||||
// after staff document review, never at accept time.
|
// The status update above already committed. Without compensation the
|
||||||
} else if (booking.scheduledDate) {
|
// client gets an error for a booking that reads as accepted after a
|
||||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
// refresh — half-applied state. Put the request back so staff can retry.
|
||||||
booking.originYardId,
|
await this.bookingsRepository.update(booking.id, {
|
||||||
booking.destinationYardId,
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
eatDay(new Date(booking.scheduledDate)),
|
fullyExecutedAt: null,
|
||||||
);
|
lockedAt: booking.lockedAt ?? null,
|
||||||
|
} as never);
|
||||||
|
this.logger.warn(
|
||||||
|
`Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
||||||
|
// batch runs after the window closes + staff document review, never at accept
|
||||||
|
// time. (Legacy pre-migration schedules with no window phase are still served
|
||||||
|
// by the periodic legacy fill.)
|
||||||
return this.bookingsService.findById(booking.id);
|
return this.bookingsService.findById(booking.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1076,31 +1086,78 @@ export class BookingTransitionService {
|
|||||||
offeredAmount: number;
|
offeredAmount: number;
|
||||||
paymentDeadline: Date;
|
paymentDeadline: Date;
|
||||||
} | null;
|
} | null;
|
||||||
|
/** Flat list of physical container numbers on this booking (for the
|
||||||
|
* customer truck-assignment container picker). */
|
||||||
|
containerNumbers: string[];
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
// This enrichment runs AFTER the transition has committed. A failure here
|
||||||
booking.id,
|
// must never 500 the response — the client would report "failed" for a
|
||||||
"CHANGES_REQUESTED",
|
// transition that actually succeeded (visible only after a refresh).
|
||||||
);
|
// Degrade each fragile field to null instead.
|
||||||
const summary =
|
let note: Awaited<
|
||||||
booking.contractSummary ??
|
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
|
||||||
this.contractService.buildContractSummary(booking);
|
> = null;
|
||||||
const nextPending =
|
try {
|
||||||
booking.status === "PENDING_APPROVAL" ||
|
note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
booking.id,
|
||||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
"CHANGES_REQUESTED",
|
||||||
: null;
|
);
|
||||||
const nextStep = computeNextStep(booking, nextPending);
|
} catch (err) {
|
||||||
const activeBatchOffer =
|
this.logger.warn(
|
||||||
booking.status === "SELECTED_FOR_BATCH"
|
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
);
|
||||||
: null;
|
}
|
||||||
|
let summary: string | null = booking.contractSummary ?? null;
|
||||||
|
try {
|
||||||
|
summary =
|
||||||
|
booking.contractSummary ??
|
||||||
|
this.contractService.buildContractSummary(booking);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let nextStep: BookingNextStep | null = null;
|
||||||
|
try {
|
||||||
|
const nextPending =
|
||||||
|
booking.status === "PENDING_APPROVAL" ||
|
||||||
|
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||||
|
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||||
|
: null;
|
||||||
|
nextStep = computeNextStep(booking, nextPending);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let activeBatchOffer: Awaited<
|
||||||
|
ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
|
||||||
|
> = null;
|
||||||
|
try {
|
||||||
|
activeBatchOffer =
|
||||||
|
booking.status === "SELECTED_FOR_BATCH"
|
||||||
|
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
||||||
|
: null;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Physical container numbers entered at booking time (booking_container
|
||||||
|
// units), flattened for the customer truck-assignment container picker.
|
||||||
|
const containerNumbers = (booking.bookingContainers ?? [])
|
||||||
|
.flatMap((bc) => bc.units ?? [])
|
||||||
|
.map((unit) => unit.containerNumber)
|
||||||
|
.filter((n): n is string => Boolean(n));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
latestChangeRequestNote: note?.note ?? null,
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
contractSummary: summary,
|
contractSummary: summary,
|
||||||
nextStep,
|
nextStep,
|
||||||
activeBatchOffer,
|
activeBatchOffer,
|
||||||
|
containerNumbers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
@@ -61,6 +62,11 @@ import {
|
|||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||||
|
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||||
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +89,8 @@ export class BookingsController {
|
|||||||
private readonly transitionService: BookingTransitionService,
|
private readonly transitionService: BookingTransitionService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
|
private readonly customerTruckService: CustomerTruckService,
|
||||||
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -309,6 +317,94 @@ export class BookingsController {
|
|||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/customer-trucks')
|
||||||
|
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||||
|
async listCustomerTrucks(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.listTrucks(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks')
|
||||||
|
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' })
|
||||||
|
async addCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AddCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.addTruck(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/customer-trucks/:assignmentId')
|
||||||
|
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||||
|
async removeCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||||
|
})
|
||||||
|
async departCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Body() dto: DepartCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Weighing + registering the load on exit is a warehouse/gate staff action.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can register a truck departure');
|
||||||
|
}
|
||||||
|
return this.customerTruckService.departTruck(id, assignmentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/received-pending-grn')
|
||||||
|
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||||||
|
async receivedPendingGrn(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// GRN is a warehouse-staff action — no customer access.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||||
|
}
|
||||||
|
return this.containerReceiptService.listReceivedPendingGrn(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/generate-grn')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||||||
|
})
|
||||||
|
async generateGrn(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: GenerateGrnDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// GRN is a warehouse-staff action — no customer access.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||||
|
}
|
||||||
|
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/tracking')
|
@Get(':id/tracking')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Shipment tracking timeline for a booking",
|
summary: "Shipment tracking timeline for a booking",
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||||
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
BookingContainerAllocation,
|
BookingContainerAllocation,
|
||||||
|
CustomerTruckAssignment,
|
||||||
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
|
CustomerTruckAssignmentsRepository,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BookingsModule { }
|
export class BookingsModule { }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
@@ -35,6 +36,7 @@ export interface BookingListFilterOptions {
|
|||||||
serviceTypeId?: string;
|
serviceTypeId?: string;
|
||||||
cargoTypeId?: string;
|
cargoTypeId?: string;
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
|
bookingType?: string;
|
||||||
tradeDirection?: string;
|
tradeDirection?: string;
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
paymentStatus?: string;
|
paymentStatus?: string;
|
||||||
@@ -89,6 +91,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||||
|
.leftJoinAndSelect('bc.units', 'bcu')
|
||||||
.leftJoinAndSelect('booking.company', 'company')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||||
.leftJoinAndSelect('booking.train', 'train')
|
.leftJoinAndSelect('booking.train', 'train')
|
||||||
@@ -103,6 +106,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||||
.where('booking.id = :id', { id })
|
.where('booking.id = :id', { id })
|
||||||
|
.addOrderBy('bcu.sort_order', 'ASC')
|
||||||
.leftJoinAndMapMany(
|
.leftJoinAndMapMany(
|
||||||
'booking.files',
|
'booking.files',
|
||||||
FileRecord,
|
FileRecord,
|
||||||
@@ -584,6 +588,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||||
|
// Contract reference for the list column + search (no entity relation on
|
||||||
|
// Booking → contract, so join the entity by id and select just the
|
||||||
|
// reference — a schema-qualified table string is parsed as alias.relation
|
||||||
|
// by TypeORM and crashes).
|
||||||
|
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||||
|
.addSelect('contract.reference', 'contract_reference')
|
||||||
.where('booking.deleted_at IS NULL');
|
.where('booking.deleted_at IS NULL');
|
||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
@@ -602,10 +612,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [items, total] = await qb
|
const total = await qb.getCount();
|
||||||
|
const { entities: items, raw } = await qb
|
||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.getManyAndCount();
|
.getRawAndEntities();
|
||||||
|
|
||||||
|
// The joined contract.reference comes back on the raw rows only (entity has no
|
||||||
|
// contract relation) — map it onto each booking by position.
|
||||||
|
const contractRefByBooking = new Map<string, string | null>();
|
||||||
|
for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) {
|
||||||
|
if (row.booking_id && !contractRefByBooking.has(row.booking_id)) {
|
||||||
|
contractRefByBooking.set(row.booking_id, row.contract_reference ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
(item as Booking & { contractReference?: string | null }).contractReference =
|
||||||
|
contractRefByBooking.get(item.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||||
@@ -737,6 +761,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
freightType: options.freightType,
|
freightType: options.freightType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (options.bookingType) {
|
||||||
|
qb.andWhere('booking.bookingType = :bookingType', {
|
||||||
|
bookingType: options.bookingType,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.createdFrom) {
|
if (options.createdFrom) {
|
||||||
qb.andWhere('booking.created_at >= :createdFrom', {
|
qb.andWhere('booking.created_at >= :createdFrom', {
|
||||||
createdFrom: options.createdFrom,
|
createdFrom: options.createdFrom,
|
||||||
@@ -1051,8 +1080,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
company: true,
|
company: true,
|
||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
bookingContainers: { containerType: true },
|
// units carry the real per-container numbers entered at booking time —
|
||||||
cargoType: true,
|
// the wagon plan shows those instead of generated placeholders.
|
||||||
|
// containerType.wagonType + cargoType.wagonType drive wagon-type
|
||||||
|
// resolution during scheduling (FK, not the old load-type string map).
|
||||||
|
bookingContainers: { containerType: { wagonType: true }, units: true },
|
||||||
|
cargoType: { wagonType: true },
|
||||||
},
|
},
|
||||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,7 +145,28 @@ export class BookingsService {
|
|||||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
const trucks: Array<{
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
containers: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT a.plate_number AS "plateNumber",
|
||||||
|
a.driver_name AS "driverName",
|
||||||
|
a.truck_type AS "truckType",
|
||||||
|
a.arrived_at AS "arrivedAt",
|
||||||
|
string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
LEFT JOIN freight.customer_truck_containers c
|
||||||
|
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||||||
|
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||||
|
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
|
||||||
|
ORDER BY a.assigned_at`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||||
return {
|
return {
|
||||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
@@ -190,37 +211,85 @@ export class BookingsService {
|
|||||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
private buildCustomerTruckFreightOrderHtml(
|
||||||
|
booking: Booking,
|
||||||
|
trucks: Array<{
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
containers: string | null;
|
||||||
|
}>,
|
||||||
|
): string {
|
||||||
const assignedAt = booking.customerTruckAssignedAt
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||||
: '-';
|
: '-';
|
||||||
const rows: Array<[string, string | null | undefined]> = [
|
const bookingRows: Array<[string, string | null | undefined]> = [
|
||||||
['Booking Reference', booking.reference],
|
['Booking Reference', booking.reference],
|
||||||
['Client Name', booking.company?.name],
|
['Client Name', booking.company?.name],
|
||||||
['Client ID', booking.companyId],
|
['Client ID', booking.companyId],
|
||||||
['Trade Direction', booking.tradeDirection],
|
['Trade Direction', booking.tradeDirection],
|
||||||
['Freight Type', booking.freightType],
|
['Freight Type', booking.freightType],
|
||||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
|
||||||
['Driver Name', booking.customerTruckDriverName],
|
|
||||||
['Truck Type', booking.customerTruckType],
|
|
||||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
|
||||||
['Assigned At', assignedAt],
|
['Assigned At', assignedAt],
|
||||||
['Booking Status', booking.status],
|
['Booking Status', booking.status],
|
||||||
];
|
];
|
||||||
const rowHtml = rows
|
const bookingRowHtml = bookingRows
|
||||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
// Fall back to the legacy single-truck booking columns when there are no
|
||||||
|
// multi-truck rows (bookings assigned before the multi-truck feature).
|
||||||
|
const truckList =
|
||||||
|
trucks.length > 0
|
||||||
|
? trucks
|
||||||
|
: booking.customerTruckPlateNumber
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
plateNumber: booking.customerTruckPlateNumber,
|
||||||
|
driverName: booking.customerTruckDriverName ?? '',
|
||||||
|
truckType: booking.customerTruckType ?? '',
|
||||||
|
arrivedAt: booking.customerTruckArrivedAt
|
||||||
|
? String(booking.customerTruckArrivedAt)
|
||||||
|
: null,
|
||||||
|
containers: booking.customerTruckContainerNumber ?? null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const truckBlocks = truckList
|
||||||
|
.map((t, i) => {
|
||||||
|
const rows: Array<[string, string | null | undefined]> = [
|
||||||
|
['Truck Plate Number', t.plateNumber],
|
||||||
|
['Driver Name', t.driverName],
|
||||||
|
['Truck Type', t.truckType],
|
||||||
|
['Containers Loaded', t.containers],
|
||||||
|
[
|
||||||
|
'Arrival',
|
||||||
|
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const html = rows
|
||||||
|
.map(
|
||||||
|
([label, value]) =>
|
||||||
|
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const copy = (watermark: string) => `
|
const copy = (watermark: string) => `
|
||||||
<section class="copy">
|
<section class="copy">
|
||||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>Freight Order</h1>
|
<h1>Freight Order</h1>
|
||||||
<p>Customer external truck assignment</p>
|
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||||
</header>
|
</header>
|
||||||
<table>${rowHtml}</table>
|
<table>${bookingRowHtml}</table>
|
||||||
|
${truckBlocks}
|
||||||
<div class="signatures">
|
<div class="signatures">
|
||||||
<div>Customer / Carrier Signature</div>
|
<div>Customer / Carrier Signature</div>
|
||||||
<div>Port Operations Verification</div>
|
<div>Port Operations Verification</div>
|
||||||
@@ -238,11 +307,13 @@ export class BookingsService {
|
|||||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||||
|
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
||||||
p { margin: 4px 0 0; color: #64748b; }
|
p { margin: 4px 0 0; color: #64748b; }
|
||||||
strong { font-size: 16px; color: #0a9f6a; }
|
strong { font-size: 16px; color: #0a9f6a; }
|
||||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||||
th { width: 34%; background: #f1f5f9; }
|
th { width: 34%; background: #f1f5f9; }
|
||||||
|
.truck { page-break-inside: avoid; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||||
</style>
|
</style>
|
||||||
@@ -1001,6 +1072,7 @@ export class BookingsService {
|
|||||||
serviceTypeId: filter.serviceTypeId,
|
serviceTypeId: filter.serviceTypeId,
|
||||||
cargoTypeId: filter.cargoTypeId,
|
cargoTypeId: filter.cargoTypeId,
|
||||||
freightType: filter.freightType,
|
freightType: filter.freightType,
|
||||||
|
bookingType: filter.bookingType,
|
||||||
tradeDirection: filter.tradeDirection,
|
tradeDirection: filter.tradeDirection,
|
||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
paymentStatus: filter.paymentStatus,
|
paymentStatus: filter.paymentStatus,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export interface ReceivedUnitRow {
|
||||||
|
id: string;
|
||||||
|
containerNumber: string;
|
||||||
|
receivedToPort: boolean;
|
||||||
|
receivedAt: string | null;
|
||||||
|
grnNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container receive + GRN tracking on booking_container_units.
|
||||||
|
*
|
||||||
|
* Containers arrive individually (on separate self-haul trucks), so each unit is
|
||||||
|
* flipped `received_to_port` when its truck arrives (auto). Staff then confirm a
|
||||||
|
* Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a
|
||||||
|
* batch, so if the whole booking arrives together every unit shares a single GRN
|
||||||
|
* (per-booking GRN); if trucks arrive separately each batch gets its own GRN.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerReceiptService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-mark the containers loaded on an arrived truck as received into the
|
||||||
|
* port. Idempotent — only flips units not already received. Runs inside the
|
||||||
|
* caller's transaction when a manager is supplied.
|
||||||
|
*/
|
||||||
|
async markReceivedForAssignment(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
await m.query(
|
||||||
|
`UPDATE freight.booking_container_units bcu
|
||||||
|
SET received_to_port = true,
|
||||||
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.booking_containers bc,
|
||||||
|
freight.customer_truck_containers ctc
|
||||||
|
WHERE bc.id = bcu.booking_container_id
|
||||||
|
AND bc.booking_id = $1
|
||||||
|
AND ctc.assignment_id = $2
|
||||||
|
AND ctc.deleted_at IS NULL
|
||||||
|
AND ctc.container_number = bcu.container_number
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = false`,
|
||||||
|
[bookingId, assignmentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Received-into-port containers that have not yet been assigned a GRN. */
|
||||||
|
async listReceivedPendingGrn(bookingId: string): Promise<ReceivedUnitRow[]> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT bcu.id,
|
||||||
|
bcu.container_number AS "containerNumber",
|
||||||
|
bcu.received_to_port AS "receivedToPort",
|
||||||
|
bcu.received_at AS "receivedAt",
|
||||||
|
bcu.grn_number AS "grnNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = true
|
||||||
|
AND bcu.grn_number IS NULL
|
||||||
|
ORDER BY bcu.received_at`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a GRN over the currently received-but-un-GRN'd containers (optionally
|
||||||
|
* a subset by container number). Assigns one GRN number to the whole batch and
|
||||||
|
* returns it with the covered containers. If the batch covers every container
|
||||||
|
* on the booking it is effectively a per-booking GRN.
|
||||||
|
*/
|
||||||
|
async generateGrn(
|
||||||
|
bookingId: string,
|
||||||
|
containerNumbers?: string[],
|
||||||
|
): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> {
|
||||||
|
const [booking] = await this.dataSource.query(
|
||||||
|
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const wanted = containerNumbers?.map((n) => n.trim().toUpperCase());
|
||||||
|
const pending: ReceivedUnitRow[] = await manager.query(
|
||||||
|
`SELECT bcu.id, bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = true
|
||||||
|
AND bcu.grn_number IS NULL
|
||||||
|
${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`,
|
||||||
|
wanted ? [bookingId, wanted] : [bookingId],
|
||||||
|
);
|
||||||
|
if (!pending.length) {
|
||||||
|
throw new BadRequestException('No received containers are awaiting a GRN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch sequence = number of GRNs already issued for this booking + 1.
|
||||||
|
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
||||||
|
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const seq = Number(batches) + 1;
|
||||||
|
const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const ids = pending.map((p) => p.id);
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_container_units
|
||||||
|
SET grn_number = $1, updated_at = NOW()
|
||||||
|
WHERE id = ANY($2::uuid[])`,
|
||||||
|
[grnNumber, ids],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-booking when no container on the booking is left un-GRN'd.
|
||||||
|
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
|
||||||
|
`SELECT COUNT(*) AS remaining
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grnNumber,
|
||||||
|
containerNumbers: pending.map((p) => p.containerNumber),
|
||||||
|
perBooking: Number(remaining) === 0 && seq === 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerTruckAssignmentsRepository extends BaseRepository<CustomerTruckAssignment> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CustomerTruckAssignment)
|
||||||
|
private readonly repo: Repository<CustomerTruckAssignment>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All trucks assigned to a booking, oldest first, with their containers. */
|
||||||
|
findByBookingId(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
relations: { containers: true },
|
||||||
|
order: { assignedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithContainers(id: string): Promise<CustomerTruckAssignment | null> {
|
||||||
|
return this.repo.findOne({ where: { id }, relations: { containers: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||||
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
|
|
||||||
|
interface BookingGuardRow {
|
||||||
|
tradeDirection: string | null;
|
||||||
|
firstMile: string | null;
|
||||||
|
lastMile: string | null;
|
||||||
|
paymentStatus: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
|
||||||
|
* can have several customer trucks, each carrying 1–2 of its containers and
|
||||||
|
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
|
||||||
|
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
|
||||||
|
* exit-gate + delivery-approval logic keep working unchanged.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerTruckService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
return this.assignments.findByBookingId(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
|
this.assertSelfHaulPaid(booking);
|
||||||
|
|
||||||
|
const isExport = booking.tradeDirection === 'EXPORT';
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
|
||||||
|
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
||||||
|
// not pre-specified — they are registered + weighed when the truck leaves.
|
||||||
|
if (isExport) {
|
||||||
|
if (requested.length < 1 || requested.length > 2) {
|
||||||
|
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||||||
|
}
|
||||||
|
} else if (requested.length > 2) {
|
||||||
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested.length) {
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (alreadyAssigned.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
|
||||||
|
manager.getRepository(CustomerTruckAssignment).create({
|
||||||
|
bookingId,
|
||||||
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
|
driverName: dto.driverName.trim(),
|
||||||
|
truckType: dto.truckType.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Booking-level flag: first truck marks the booking as truck-assigned.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
|
||||||
|
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
if (assignment.arrivedAt) {
|
||||||
|
throw new ConflictException('Cannot remove a truck that has already arrived');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
|
||||||
|
const remaining = await manager
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.count({ where: { bookingId } });
|
||||||
|
if (remaining === 0) {
|
||||||
|
// No trucks left — clear the booking-level flag and revert the status.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_assigned_at = NULL,
|
||||||
|
status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||||
|
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||||
|
* Export bookings have no truck departure — trucks only deliver (receive).
|
||||||
|
*/
|
||||||
|
async departTruck(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
dto: DepartCustomerTruckDto,
|
||||||
|
): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
// Once filled, the departure record is uneditable.
|
||||||
|
if (assignment.departedAt) {
|
||||||
|
throw new ConflictException('This truck has already departed — its exit record is locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
if (requested.length) {
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (elsewhere.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (requested.length) {
|
||||||
|
// Replace the truck's containers with what was actually loaded.
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
|
grossWeightKg: dto.grossWeightKg,
|
||||||
|
departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
|
||||||
|
arrivedAt: assignment.arrivedAt ?? new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
|
||||||
|
* receive flow. When every truck on the booking has arrived, the booking-level
|
||||||
|
* customer_truck_arrived_at flag is stamped (used by the delivery-approval
|
||||||
|
* gate). No-op when the container is not on any customer truck.
|
||||||
|
*/
|
||||||
|
async markArrivedByContainer(
|
||||||
|
bookingId: string,
|
||||||
|
containerNumber: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const cn = containerNumber.trim().toUpperCase();
|
||||||
|
const container = await m.getRepository(CustomerTruckContainer).findOne({
|
||||||
|
where: { bookingId, containerNumber: cn },
|
||||||
|
});
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
|
|
||||||
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark every truck on the booking arrived (fallback when no container is known). */
|
||||||
|
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp the booking-level arrival flag on the FIRST truck arrival. The import
|
||||||
|
* handover is signed once, before the first truck leaves, even though trucks
|
||||||
|
* pick up per-container — so the flag fires on the first arrival (COALESCE
|
||||||
|
* keeps it), not once all trucks have arrived.
|
||||||
|
*/
|
||||||
|
private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
|
||||||
|
await m.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
|
||||||
|
const [row]: BookingGuardRow[] = await this.dataSource.query(
|
||||||
|
`SELECT trade_direction AS "tradeDirection",
|
||||||
|
first_mile_pickup_address AS "firstMile",
|
||||||
|
last_mile_delivery_address AS "lastMile",
|
||||||
|
payment_status AS "paymentStatus",
|
||||||
|
status
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||||
|
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||||
|
const hasLastMile = Boolean(booking.lastMile?.trim());
|
||||||
|
const usesMileService =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? hasLastMile
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? hasFirstMile
|
||||||
|
: hasFirstMile || hasLastMile;
|
||||||
|
if (usesMileService) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Booking must be paid before assigning an external customer truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT container_number AS "containerNumber"
|
||||||
|
FROM freight.customer_truck_containers
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assignedContainerNumbersExcept(
|
||||||
|
bookingId: string,
|
||||||
|
exceptAssignmentId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT container_number AS "containerNumber"
|
||||||
|
FROM freight.customer_truck_containers
|
||||||
|
WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
|
||||||
|
[bookingId, exceptAssignmentId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one external customer truck to a booking.
|
||||||
|
* - EXPORT: the truck delivers 1–2 known containers (required, validated in the
|
||||||
|
* service against the booking's containers).
|
||||||
|
* - IMPORT: the customer does not pre-specify — containers are registered and
|
||||||
|
* weighed when the truck leaves, so `containerNumbers` may be omitted/empty.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(32)
|
||||||
|
truckPlateNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(120)
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||||
|
truckType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an import self-haul truck leaving the port: the containers it actually
|
||||||
|
* loaded (staff read them off the truck) and the weighed gross. Container numbers
|
||||||
|
* are optional here only because they may already have been recorded; the weighed
|
||||||
|
* gross is required.
|
||||||
|
*/
|
||||||
|
export class DepartCustomerTruckDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
grossWeightKg!: number;
|
||||||
|
|
||||||
|
/** Gate-out time. Defaults to now when omitted. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
gateOutTime?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a Goods Received Note. Omit `containerNumbers` to GRN every
|
||||||
|
* received-but-un-GRN'd container on the booking (per-booking when that's all of
|
||||||
|
* them); pass a subset to GRN just those.
|
||||||
|
*/
|
||||||
|
export class GenerateGrnDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
}
|
||||||
@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
|
|
||||||
|
/** Whether this container has been received into the port (auto-set when its
|
||||||
|
* self-haul truck arrives). */
|
||||||
|
@Column({ name: 'received_to_port', type: 'boolean', default: false })
|
||||||
|
receivedToPort!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'received_at', type: 'timestamptz', nullable: true })
|
||||||
|
receivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** The GRN this container was received under (assigned when staff confirm the
|
||||||
|
* Goods Received Note for a batch of received containers). */
|
||||||
|
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
|
||||||
|
grnNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||||
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||||
import { Booking } from './booking.entity';
|
import { Booking } from './booking.entity';
|
||||||
|
import { BookingContainerUnit } from './booking-container-unit.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'booking_container' })
|
@Entity({ schema: 'freight', name: 'booking_container' })
|
||||||
@Index(['bookingId'])
|
@Index(['bookingId'])
|
||||||
@@ -61,4 +62,8 @@ export class BookingContainer extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
@Column({ name: 'overweight_excess_tons', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
overweightExcessTons?: number | null;
|
overweightExcessTons?: number | null;
|
||||||
|
|
||||||
|
/** The physical containers under this line — each with its own number + VGM. */
|
||||||
|
@OneToMany(() => BookingContainerUnit, (u) => u.bookingContainer)
|
||||||
|
units?: BookingContainerUnit[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||||
contractRouteId?: string | null;
|
contractRouteId?: string | null;
|
||||||
|
|
||||||
|
/** Booking origin: ONE_TIME (single-shipment) or GENERAL_CONTRACT (drawdown). */
|
||||||
|
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||||
|
bookingType!: string;
|
||||||
|
|
||||||
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
|
||||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||||
contractKind?: string | null;
|
contractKind?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
import { CustomerTruckContainer } from './customer-truck-container.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One external (self-haul) truck a customer assigns to a booking that has no
|
||||||
|
* EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own
|
||||||
|
* arrival at the terminal/warehouse.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'customer_truck_assignments' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class CustomerTruckAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'plate_number', type: 'varchar', length: 32 })
|
||||||
|
plateNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'driver_name', type: 'varchar', length: 120 })
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'truck_type', type: 'varchar', length: 60 })
|
||||||
|
truckType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
|
assignedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
arrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** Weighed gross of what the truck actually loaded (import), captured on
|
||||||
|
* leaving. Null until the truck departs. */
|
||||||
|
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
|
grossWeightKg?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
@OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true })
|
||||||
|
containers?: CustomerTruckContainer[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { CustomerTruckAssignment } from './customer-truck-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container number loaded onto a customer truck. A container may be loaded
|
||||||
|
* onto exactly one truck per booking (enforced by a partial unique index on
|
||||||
|
* booking_id + container_number).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'customer_truck_containers' })
|
||||||
|
@Index(['assignmentId'])
|
||||||
|
export class CustomerTruckContainer extends BaseEntity {
|
||||||
|
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||||
|
assignmentId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'assignment_id' })
|
||||||
|
assignment?: CustomerTruckAssignment;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
|
containerNumber!: string;
|
||||||
|
}
|
||||||
@@ -334,15 +334,19 @@ export class CompaniesController {
|
|||||||
@Param("companyId", ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
) {
|
) {
|
||||||
const files = await this.filesService.findByResource(companyId, "companies");
|
const files = await this.filesService.findByResource(companyId, "companies");
|
||||||
return files.map((f) => ({
|
return Promise.all(
|
||||||
id: f.id,
|
files.map(async (f) => ({
|
||||||
name: f.name,
|
id: f.id,
|
||||||
code: f.code,
|
name: f.name,
|
||||||
mimeType: f.mimeType,
|
code: f.code,
|
||||||
size: f.size,
|
mimeType: f.mimeType,
|
||||||
uploadedAt: f.createdAt,
|
size: f.size,
|
||||||
url: f.url,
|
uploadedAt: f.createdAt,
|
||||||
}));
|
// Raw `f.url` is an un-signed MinIO path the browser can't open — sign
|
||||||
|
// it so the file previews/downloads in the client.
|
||||||
|
url: f.url ? await this.filesService.signUrl(f.url) : f.url,
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(":companyId/documents")
|
@Post(":companyId/documents")
|
||||||
|
|||||||
@@ -1183,9 +1183,11 @@ export class CompaniesService {
|
|||||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
||||||
if (!businessInfo) {
|
if (!businessInfo) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"No business license found for this TIN. Please check the number and try again.",
|
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.etradeService.extractRegistrationData(businessInfo);
|
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
||||||
|
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||||
|
return { ...registrationData, tinTaken };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
|
||||||
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
|
|||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@Length(10, 10)
|
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||||
@Matches(/^00\d{8}$/, {
|
|
||||||
message: 'TIN must be 10 digits starting with 00',
|
|
||||||
})
|
|
||||||
tin!: string;
|
tin!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
managerName!: string;
|
managerName!: string;
|
||||||
managerEmail?: string;
|
managerEmail?: string;
|
||||||
managerPhone!: string;
|
managerPhone!: string;
|
||||||
|
tinTaken?: boolean;
|
||||||
|
|
||||||
constructor(data: CompanyRegistrationData) {
|
constructor(data: CompanyRegistrationData) {
|
||||||
this.licenceNumber = data.licenceNumber;
|
this.licenceNumber = data.licenceNumber;
|
||||||
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
this.managerName = data.managerName;
|
this.managerName = data.managerName;
|
||||||
this.managerEmail = data.managerEmail;
|
this.managerEmail = data.managerEmail;
|
||||||
this.managerPhone = data.managerPhone;
|
this.managerPhone = data.managerPhone;
|
||||||
|
this.tinTaken = data.tinTaken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
|
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from '../entities/company.entity';
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
||||||
|
|
||||||
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(10, 10)
|
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
|
||||||
@Matches(/^00\d{8}$/, {
|
|
||||||
message: 'TIN must be 10 digits starting with 00',
|
|
||||||
})
|
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export class BookingClearanceService {
|
|||||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
milestones.find((m) => m.milestoneCode === code);
|
milestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||||
@@ -242,14 +242,8 @@ export class BookingClearanceService {
|
|||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
forwardRef,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@@ -12,9 +14,11 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
|||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -62,6 +66,8 @@ export class ContractBookingService {
|
|||||||
private readonly workflowService: ClearanceWorkflowService,
|
private readonly workflowService: ClearanceWorkflowService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
private readonly invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUnderContract(
|
async createUnderContract(
|
||||||
@@ -113,6 +119,32 @@ export class ContractBookingService {
|
|||||||
const generalCustoms =
|
const generalCustoms =
|
||||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||||
|
|
||||||
|
// Booking-window gate (config-driven): an operations booking may only be
|
||||||
|
// created while the route's booking window is open — import: the day's window
|
||||||
|
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||||||
|
// export: within exportBookingLeadHours of departure. Customs Path B bookings
|
||||||
|
// enter clearance first and are scheduled later, so they are not gated here.
|
||||||
|
if (!generalCustoms) {
|
||||||
|
await this.trainSchedulingService.assertBookingWindowOpen({
|
||||||
|
originYardId: route?.originYardId ?? null,
|
||||||
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
|
scheduledDate: dto.scheduledDate ?? null,
|
||||||
|
direction: contract.tradeDirection ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard capacity gate: a container line whose total weight exceeds the
|
||||||
|
// container type's max capacity can never be booked — no surcharge path,
|
||||||
|
// no override. Checked before any row is written.
|
||||||
|
if (freightType === 'CONTAINER') {
|
||||||
|
await this.assertWithinMaxCapacity(contract, dto);
|
||||||
|
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
|
||||||
|
// ≤ the cap, and drawdown bookings never pass through submit — so this is
|
||||||
|
// their only chance to hard-block an unbalanceable set. Entry order is
|
||||||
|
// irrelevant (the check sorts by weight before pairing).
|
||||||
|
await this.assert20ftPairableAtCreate(dto);
|
||||||
|
}
|
||||||
|
|
||||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
@@ -566,11 +598,14 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-create validation for the shipment form: run the overweight rule + the
|
* Pre-create validation + authoritative price preview for the shipment form:
|
||||||
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
|
||||||
* booking. The portal calls this from the price-confirm modal so the customer
|
* would persist it and run the same BookingPricingService compute over it —
|
||||||
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
|
||||||
* un-pairable 20ft set before the booking is created.
|
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
|
||||||
|
* backoffice form call this from the price-confirm modal, so the breakdown the
|
||||||
|
* user confirms is line-for-line what the booking will be charged. Also runs
|
||||||
|
* the 20ft weight-pairing rule, which hard-blocks creation.
|
||||||
*/
|
*/
|
||||||
async validateShipment(
|
async validateShipment(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
@@ -582,16 +617,31 @@ export class ContractBookingService {
|
|||||||
maxAllowedTons: number;
|
maxAllowedTons: number;
|
||||||
excessTons: number;
|
excessTons: number;
|
||||||
}>;
|
}>;
|
||||||
|
overweightSurchargeAmount: number;
|
||||||
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
|
capacityErrors: string[];
|
||||||
|
lineItems: PriceLineItemDto[];
|
||||||
|
totalAmount: number;
|
||||||
}> {
|
}> {
|
||||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||||
|
|
||||||
const lines = dto.containers ?? [];
|
const lines = dto.containers ?? [];
|
||||||
if (!lines.length) return { overweightLines: [], pairingErrors: [] };
|
if (contract.freightType === 'CONTAINER' && !lines.length) {
|
||||||
|
return {
|
||||||
|
overweightLines: [],
|
||||||
|
overweightSurchargeAmount: 0,
|
||||||
|
currency: null,
|
||||||
|
pairingErrors: [],
|
||||||
|
capacityErrors: [],
|
||||||
|
lineItems: [],
|
||||||
|
totalAmount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
// Resolve each container line's type + total VGM (sum of unit weights) —
|
||||||
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
// mirrors persistContainers so the preview lines match the persisted ones.
|
||||||
const resolved = await Promise.all(
|
const resolved = await Promise.all(
|
||||||
lines.map(async (line) => {
|
lines.map(async (line) => {
|
||||||
const ct = await this.resolveContainerTypeForSize(
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
@@ -606,46 +656,44 @@ export class ContractBookingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ruleResult = await this.ruleEngineService.evaluate({
|
// The unsaved twin of the booking createUnderContract would write: same
|
||||||
freightType: 'CONTAINER',
|
// denormalized contract fields, same container-line math. No id → the
|
||||||
cargoTypeId: null,
|
// pricing service derives wagon counts from the in-memory lines.
|
||||||
serviceTypeId: contract.serviceTypeId,
|
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||||
paymentCurrency: contract.paymentCurrency,
|
const previewBooking = Object.assign(new Booking(), {
|
||||||
|
freightType: contract.freightType,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
isHazardous: false,
|
paymentCurrency: contract.paymentCurrency,
|
||||||
isReefer: contract.isReefer ?? false,
|
serviceTypeId: contract.serviceTypeId,
|
||||||
isGovernment: false,
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
allowConsolidation: false,
|
isHazardous: contract.isHazardous,
|
||||||
|
isReefer: contract.isReefer,
|
||||||
|
isGovernment: contract.isGovernment,
|
||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
totalWagons: 0,
|
contractRouteId: route?.id ?? null,
|
||||||
bulkTons: 0,
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
containers: resolved.map((r) => ({
|
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||||
containerTypeId: r.ct.id,
|
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||||
quantity: r.line.quantity,
|
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||||
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
Object.assign(new BookingContainer(), {
|
||||||
totalVgmTons: r.totalVgmTons,
|
containerTypeId: ct.id,
|
||||||
isReefer: r.ct.isReefer,
|
containerSize: line.containerSize,
|
||||||
})),
|
quantity: line.quantity,
|
||||||
} as never);
|
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||||
|
reeferQuantity: line.reeferQuantity ?? 0,
|
||||||
|
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||||
|
totalVgmTons,
|
||||||
|
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}) as Booking;
|
||||||
|
|
||||||
const overweightLines: Array<{
|
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
|
||||||
containerTypeCode: string;
|
|
||||||
totalVgmTons: number;
|
// The overweight surcharge line is already currency-converted; surface its
|
||||||
maxAllowedTons: number;
|
// amount separately so the warning alert can reference the exact charge.
|
||||||
excessTons: number;
|
const overweightSurchargeAmount =
|
||||||
}> = [];
|
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
|
||||||
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
|
||||||
const wr = ruleResult.containerWeightResults[i];
|
|
||||||
if (!wr?.isOverweight) continue;
|
|
||||||
const r = resolved[i];
|
|
||||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
|
||||||
overweightLines.push({
|
|
||||||
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
|
|
||||||
totalVgmTons: r?.totalVgmTons ?? 0,
|
|
||||||
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
|
|
||||||
excessTons,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||||
const twentyFtUnits = resolved
|
const twentyFtUnits = resolved
|
||||||
@@ -661,7 +709,90 @@ export class ContractBookingService {
|
|||||||
(v) => v.message,
|
(v) => v.message,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { overweightLines, pairingErrors };
|
// Hard capacity ceiling — a non-empty result means the create call will be
|
||||||
|
// rejected, so the form can block submit up front.
|
||||||
|
const capacityErrors = await this.ruleEngineService.capacityViolations(
|
||||||
|
resolved.map(({ line, ct, totalVgmTons }) => ({
|
||||||
|
containerTypeId: ct.id,
|
||||||
|
quantity: line.quantity,
|
||||||
|
totalVgmTons,
|
||||||
|
})),
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
overweightLines: computed.overweightLines,
|
||||||
|
overweightSurchargeAmount,
|
||||||
|
currency: computed.currency,
|
||||||
|
pairingErrors,
|
||||||
|
capacityErrors,
|
||||||
|
lineItems: computed.lineItems,
|
||||||
|
totalAmount: computed.totalAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws when any container line's total weight exceeds the hard capacity
|
||||||
|
* ceiling of its weight limit rule. Mirrors validateShipment's line
|
||||||
|
* resolution so the gate matches what the form preview reported.
|
||||||
|
*/
|
||||||
|
private async assertWithinMaxCapacity(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const lines = dto.containers ?? [];
|
||||||
|
if (!lines.length) return;
|
||||||
|
|
||||||
|
const containers = await Promise.all(
|
||||||
|
lines.map(async (line) => {
|
||||||
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
|
line.containerSize,
|
||||||
|
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const totalVgmTons = (line.units ?? []).reduce(
|
||||||
|
(s, u) => s + Number(u.vgmTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const violations = await this.ruleEngineService.capacityViolations(
|
||||||
|
containers,
|
||||||
|
contract.tradeDirection,
|
||||||
|
);
|
||||||
|
if (violations.length) {
|
||||||
|
throw new BadRequestException(violations.join('; '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard-block booking creation when the 20ft container weights cannot be
|
||||||
|
* balanced onto wagons (pair diff over the global cap). Same rule the
|
||||||
|
* shipment-form preview reports as `pairingErrors`, enforced server-side.
|
||||||
|
*/
|
||||||
|
private async assert20ftPairableAtCreate(
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const twentyFtUnits = (dto.containers ?? [])
|
||||||
|
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||||
|
.flatMap((line, lineIdx) =>
|
||||||
|
(line.units ?? []).map((u, idx) => ({
|
||||||
|
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (twentyFtUnits.length < 2) return;
|
||||||
|
|
||||||
|
const maxDiff = await this.max20ftPairDiffTons();
|
||||||
|
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
|
||||||
|
if (violations.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
|
||||||
|
.map((v) => v.message)
|
||||||
|
.join(' ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async max20ftPairDiffTons(): Promise<number> {
|
private async max20ftPairDiffTons(): Promise<number> {
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export interface ContractClearanceView {
|
|||||||
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
|
||||||
exportClearanceFinalized?: boolean;
|
exportClearanceFinalized?: boolean;
|
||||||
linkedBookingId?: string | null;
|
linkedBookingId?: string | null;
|
||||||
|
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||||
|
linkedBookingReference?: string | null;
|
||||||
|
linkedBookingStatus?: string | null;
|
||||||
dutyAdvice?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -275,7 +278,9 @@ export class ContractClearanceService {
|
|||||||
}
|
}
|
||||||
const bookingMilestone = (code: string) =>
|
const bookingMilestone = (code: string) =>
|
||||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
const gatepass = cycle?.bookingId
|
||||||
|
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
|
||||||
|
: { granted: false, grantedAt: null };
|
||||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||||
const secondDuty = this.glOperationsService.secondDutyState(
|
const secondDuty = this.glOperationsService.secondDutyState(
|
||||||
@@ -284,13 +289,22 @@ export class ContractClearanceService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
|
||||||
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
|
// Once GL creates the shipment booking, surface its reference + status so the
|
||||||
|
// customer sees the concrete booking instead of a stale "will be created
|
||||||
|
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||||
|
let linkedBookingReference: string | null = null;
|
||||||
|
let linkedBookingStatus: string | null = null;
|
||||||
|
if (cycle?.bookingId) {
|
||||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||||
if (booking) {
|
if (booking) {
|
||||||
nextAction = this.workflowService.computeNextActionForBooking(
|
linkedBookingReference = booking.reference ?? null;
|
||||||
booking,
|
linkedBookingStatus = booking.status ?? null;
|
||||||
bookingMilestones,
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
);
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
|
booking,
|
||||||
|
bookingMilestones,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,18 +340,14 @@ export class ContractClearanceService {
|
|||||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||||
linkedBookingId: cycle?.bookingId ?? null,
|
linkedBookingId: cycle?.bookingId ?? null,
|
||||||
|
linkedBookingReference,
|
||||||
|
linkedBookingStatus,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
t1,
|
||||||
train,
|
train,
|
||||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
gatepassGranted: gatepass.granted,
|
||||||
gatepassAt:
|
gatepassAt: gatepass.grantedAt,
|
||||||
gatepassMilestone?.status === 'COMPLETED'
|
|
||||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
|
||||||
(gatepassMilestone.triggeredAt
|
|
||||||
? gatepassMilestone.triggeredAt.toISOString()
|
|
||||||
: null))
|
|
||||||
: null,
|
|
||||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||||
t1ClosedAt:
|
t1ClosedAt:
|
||||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ import {
|
|||||||
} from './dto/gl-operations.dto';
|
} from './dto/gl-operations.dto';
|
||||||
import {
|
import {
|
||||||
AdviseContractDutyDto,
|
AdviseContractDutyDto,
|
||||||
GatepassDto,
|
|
||||||
RoAmendmentDto,
|
RoAmendmentDto,
|
||||||
} from './dto/phased-clearance.dto';
|
} from './dto/phased-clearance.dto';
|
||||||
|
|
||||||
@@ -688,30 +687,6 @@ export class ContractsController {
|
|||||||
return this.clearanceService.djQueue(filter);
|
return this.clearanceService.djQueue(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('clearance/dj-schedules')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
|
||||||
djClearanceSchedules() {
|
|
||||||
return this.glOperationsService.djSchedules();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('clearance/schedules/:scheduleId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
|
||||||
})
|
|
||||||
grantScheduleGatepass(
|
|
||||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantScheduleGatepass(
|
|
||||||
scheduleId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||||
|
|
||||||
@Get('clearance/ops-queue')
|
@Get('clearance/ops-queue')
|
||||||
@@ -791,7 +766,7 @@ export class ContractsController {
|
|||||||
@Post(':id/validate-shipment')
|
@Post(':id/validate-shipment')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
|
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||||
})
|
})
|
||||||
validateShipment(
|
validateShipment(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@@ -947,21 +922,6 @@ export class ContractsController {
|
|||||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/gatepass')
|
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
|
||||||
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
|
||||||
grantGatepass(
|
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
|
||||||
@Body() dto: GatepassDto,
|
|
||||||
@CurrentUser() user: AuthUserPayload,
|
|
||||||
) {
|
|
||||||
return this.glOperationsService.grantGatepass(
|
|
||||||
bookingId,
|
|
||||||
dto?.gatepassAt,
|
|
||||||
resolveAuthUserId(user),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('bookings/:bookingId/final-invoice')
|
@Post('bookings/:bookingId/final-invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.m
|
|||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { OtpModule } from '../otp/otp.module';
|
import { OtpModule } from '../otp/otp.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
import { ContractsController } from './contracts.controller';
|
import { ContractsController } from './contracts.controller';
|
||||||
import { ContractsService } from './contracts.service';
|
import { ContractsService } from './contracts.service';
|
||||||
@@ -78,6 +79,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
|||||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
|
// TrainSchedulingModule provides the config-driven booking-window gate used
|
||||||
|
// by ContractBookingService.createUnderContract. forwardRef because
|
||||||
|
// TrainSchedulingModule already imports ContractsModule.
|
||||||
|
forwardRef(() => TrainSchedulingModule),
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
// Attach the generated contract PDF to each row so list/home can offer a
|
// Attach the generated contract PDF to each row so list/home can offer a
|
||||||
// direct download. Loaded separately to keep pagination counts correct.
|
// direct download. Loaded separately to keep pagination counts correct.
|
||||||
await this.attachContractFiles(items);
|
await this.attachContractFiles(items);
|
||||||
|
await this.attachClearancePhases(items);
|
||||||
|
|
||||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
return {
|
return {
|
||||||
@@ -173,6 +174,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach each contract's persisted clearance phase (latest cycle's
|
||||||
|
* current_phase) so list consumers can show step-accurate customer actions
|
||||||
|
* ("Pay duty & upload slip" vs generic "Update clearance") without a
|
||||||
|
* per-contract clearance-view request. One query per page, like
|
||||||
|
* `attachContractFiles`.
|
||||||
|
*/
|
||||||
|
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||||
|
if (contracts.length === 0) return;
|
||||||
|
const ids = contracts.map((c) => c.id);
|
||||||
|
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||||
|
FROM freight.contract_clearance_cycles
|
||||||
|
WHERE contract_id = ANY($1)
|
||||||
|
ORDER BY contract_id, cycle_number DESC`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||||
|
for (const contract of contracts) {
|
||||||
|
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.repository
|
const rows = await this.repository
|
||||||
.createQueryBuilder('contract')
|
.createQueryBuilder('contract')
|
||||||
|
|||||||
@@ -36,11 +36,3 @@ export class RoAmendmentDto {
|
|||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatepassDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
gatepassAt?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -254,4 +254,10 @@ export class Contract extends BaseEntity {
|
|||||||
createForeignKeyConstraints: false,
|
createForeignKeyConstraints: false,
|
||||||
})
|
})
|
||||||
files?: FileRecord[];
|
files?: FileRecord[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest clearance cycle's current_phase, attached by
|
||||||
|
* ContractsRepository.attachClearancePhases for list responses. Not a column.
|
||||||
|
*/
|
||||||
|
clearancePhase?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, In, IsNull } from 'typeorm';
|
import { DataSource, IsNull } from 'typeorm';
|
||||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||||
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
ClearanceIncident,
|
ClearanceIncident,
|
||||||
IncidentType,
|
IncidentType,
|
||||||
} from './entities/clearance-incident.entity';
|
} from './entities/clearance-incident.entity';
|
||||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
|
||||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||||
import {
|
import {
|
||||||
@@ -198,6 +197,7 @@ export class GlOperationsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
scheduleId: schedule?.id ?? null,
|
||||||
wagonAllocated,
|
wagonAllocated,
|
||||||
departedAt: schedule?.actualDepartureAt
|
departedAt: schedule?.actualDepartureAt
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
? new Date(schedule.actualDepartureAt).toISOString()
|
||||||
@@ -208,6 +208,41 @@ export class GlOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate pass status for a booking, sourced from the train schedule's Djibouti
|
||||||
|
* gate-pass operation (secured via the train-scheduling "Save as Secured"
|
||||||
|
* action) rather than a clearance milestone. For EXPORT bookings this also
|
||||||
|
* backfills the arrival-chain milestones once secured, same as the retired
|
||||||
|
* clearance-side grant action used to.
|
||||||
|
*/
|
||||||
|
async gatepassForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ granted: boolean; grantedAt: string | null }> {
|
||||||
|
const train = await this.trainState(bookingId);
|
||||||
|
if (!train.scheduleId) return { granted: false, grantedAt: null };
|
||||||
|
const operation = await this.dataSource
|
||||||
|
.getRepository(ImportDjiboutiOperation)
|
||||||
|
.findOne({ where: { trainScheduleId: train.scheduleId } });
|
||||||
|
const grantedAt = operation?.gatepassGrantedAt
|
||||||
|
? new Date(operation.gatepassGrantedAt).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (grantedAt) {
|
||||||
|
const booking = await this.getBooking(bookingId);
|
||||||
|
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
|
||||||
|
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||||
|
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||||
|
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||||
|
if (byCode.get(code)?.status === 'PENDING') {
|
||||||
|
await this.milestoneService.completeForBooking(bookingId, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { granted: Boolean(grantedAt), grantedAt };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
* T1 transit-document lifecycle state for an import shipment booking. Wagon
|
||||||
* allocation opens the upload window; train departure locks it; train arrival
|
* allocation opens the upload window; train departure locks it; train arrival
|
||||||
@@ -302,8 +337,11 @@ export class GlOperationsService {
|
|||||||
'The transport document must be uploaded before T1 can be closed.',
|
'The transport document must be uploaded before T1 can be closed.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!done('GATEPASS_GRANTED')) {
|
const gatepass = await this.gatepassForBooking(bookingId);
|
||||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
if (!gatepass.granted) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Secure the Djibouti gate pass on the train schedule before closing T1.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||||
@@ -322,182 +360,6 @@ export class GlOperationsService {
|
|||||||
'ARRIVED_AT_DJIBOUTI',
|
'ARRIVED_AT_DJIBOUTI',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
|
||||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
|
||||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
|
||||||
*/
|
|
||||||
async grantGatepass(
|
|
||||||
bookingId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
|
||||||
const booking = await this.getBooking(bookingId);
|
|
||||||
if (!booking.customsClearingEnabled) {
|
|
||||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
|
||||||
}
|
|
||||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
|
||||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
|
||||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
|
||||||
|
|
||||||
const existing = byCode.get('GATEPASS_GRANTED');
|
|
||||||
if (existing?.status === 'COMPLETED') {
|
|
||||||
return {
|
|
||||||
bookingId,
|
|
||||||
gatepassAt:
|
|
||||||
existing.metadata?.gatepassAt ??
|
|
||||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const train = await this.trainState(bookingId);
|
|
||||||
if (tradeDirection === 'EXPORT') {
|
|
||||||
if (!train.arrivedAt) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
|
||||||
if (byCode.get(code)?.status === 'PENDING') {
|
|
||||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (!train.wagonAllocated) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Wagons must be allocated before the gate pass can be granted.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
|
||||||
await this.milestoneService.completeWithMetadataForBooking(
|
|
||||||
bookingId,
|
|
||||||
'GATEPASS_GRANTED',
|
|
||||||
{ gatepassAt: at },
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
return { bookingId, gatepassAt: at };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
|
||||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
|
||||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
|
||||||
relations: {
|
|
||||||
scheduleBookings: { booking: true },
|
|
||||||
originStation: true,
|
|
||||||
destinationStation: true,
|
|
||||||
},
|
|
||||||
order: { scheduledDepartureDate: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const withCustoms = schedules
|
|
||||||
.filter((s) => s.status !== 'CANCELLED')
|
|
||||||
.map((s) => ({
|
|
||||||
schedule: s,
|
|
||||||
customs: (s.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
|
||||||
}))
|
|
||||||
.filter((s) => s.customs.length > 0);
|
|
||||||
|
|
||||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
|
||||||
const gatepassRows = bookingIds.length
|
|
||||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
|
||||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
|
||||||
|
|
||||||
return withCustoms.map(({ schedule, customs }) => {
|
|
||||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
|
||||||
return {
|
|
||||||
id: schedule.id,
|
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
|
||||||
routeName: null,
|
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
|
||||||
destination:
|
|
||||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
|
||||||
status: schedule.status,
|
|
||||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
|
||||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
|
||||||
: null,
|
|
||||||
actualDepartureAt: schedule.actualDepartureAt
|
|
||||||
? new Date(schedule.actualDepartureAt).toISOString()
|
|
||||||
: null,
|
|
||||||
actualArrivalAt: schedule.actualArrivalAt
|
|
||||||
? new Date(schedule.actualArrivalAt).toISOString()
|
|
||||||
: null,
|
|
||||||
freightType:
|
|
||||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
|
||||||
customsBookings: customs.map((b) => {
|
|
||||||
const m = gatepassByBooking.get(b.id);
|
|
||||||
const granted = m?.status === 'COMPLETED';
|
|
||||||
return {
|
|
||||||
bookingId: b.id,
|
|
||||||
reference: b.reference ?? b.id,
|
|
||||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
|
||||||
contractId: b.contractId ?? null,
|
|
||||||
gatepassGranted: granted,
|
|
||||||
gatepassAt: granted
|
|
||||||
? (m?.metadata?.gatepassAt ??
|
|
||||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
|
||||||
* guard failures are collected, not fatal. Import schedules also get the
|
|
||||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
|
||||||
*/
|
|
||||||
async grantScheduleGatepass(
|
|
||||||
scheduleId: string,
|
|
||||||
gatepassAt?: string,
|
|
||||||
userId?: string,
|
|
||||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
|
||||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
|
||||||
where: { id: scheduleId },
|
|
||||||
relations: { scheduleBookings: { booking: true } },
|
|
||||||
});
|
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
||||||
|
|
||||||
const customs = (schedule.scheduleBookings ?? [])
|
|
||||||
.map((sb) => sb.booking)
|
|
||||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
|
||||||
if (customs.length === 0) {
|
|
||||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let granted = 0;
|
|
||||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
|
||||||
for (const booking of customs) {
|
|
||||||
try {
|
|
||||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
|
||||||
granted += 1;
|
|
||||||
} catch (e) {
|
|
||||||
skipped.push({
|
|
||||||
bookingId: booking.id,
|
|
||||||
error: e instanceof Error ? e.message : 'Failed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
|
||||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
|
||||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
|
||||||
if (!operation) {
|
|
||||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
|
||||||
}
|
|
||||||
if (!operation.gatepassGrantedAt) {
|
|
||||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
|
||||||
await opRepo.save(operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { granted, skipped };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ import { FleetManage, FleetView } from '../../common/booking-guards';
|
|||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
|
||||||
@ApiTags('drivers')
|
@ApiTags('drivers')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
@FleetView()
|
@FleetView()
|
||||||
export class DriversController {
|
export class DriversController {
|
||||||
constructor(private readonly driversService: DriversService) {}
|
constructor(
|
||||||
|
private readonly driversService: DriversService,
|
||||||
|
private readonly fleetHistory: FleetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@@ -55,6 +59,12 @@ export class DriversController {
|
|||||||
return this.driversService.findById(id);
|
return this.driversService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/history')
|
||||||
|
@ApiOperation({ summary: 'Get driver assignment & activity history' })
|
||||||
|
history(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.fleetHistory.getDriverHistory(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@FleetManage()
|
@FleetManage()
|
||||||
@ApiOperation({ summary: 'Update a driver' })
|
@ApiOperation({ summary: 'Update a driver' })
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CreateDriverDto } from './dto/create-driver.dto';
|
import { CreateDriverDto } from './dto/create-driver.dto';
|
||||||
import { UpdateDriverDto } from './dto/update-driver.dto';
|
import { UpdateDriverDto } from './dto/update-driver.dto';
|
||||||
import { Driver, DriverStatus } from './entities/driver.entity';
|
import { Driver, DriverStatus } from './entities/driver.entity';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriversService {
|
export class DriversService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Driver)
|
@InjectRepository(Driver)
|
||||||
private readonly driverRepo: Repository<Driver>,
|
private readonly driverRepo: Repository<Driver>,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(dto: CreateDriverDto): Promise<Driver> {
|
async create(dto: CreateDriverDto): Promise<Driver> {
|
||||||
|
if (dto.faydaVerified !== true) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Driver identity must be verified with Fayda before saving',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.driverRepo.findOne({
|
const existing = await this.driverRepo.findOne({
|
||||||
where: [
|
where: [
|
||||||
{ licenseNumber: dto.licenseNumber },
|
{ licenseNumber: dto.licenseNumber },
|
||||||
@@ -33,8 +42,28 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.faydaSub) {
|
||||||
|
const dupe = await this.driverRepo.findOne({
|
||||||
|
where: { faydaSub: dto.faydaSub },
|
||||||
|
});
|
||||||
|
if (dupe) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A driver is already registered for this Fayda identity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const driver = this.driverRepo.create(dto);
|
const driver = this.driverRepo.create(dto);
|
||||||
return this.driverRepo.save(driver);
|
const saved = await this.driverRepo.save(driver);
|
||||||
|
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.DRIVER_REGISTERED,
|
||||||
|
driverId: saved.id,
|
||||||
|
label: `${saved.firstName ?? ''} ${saved.lastName ?? ''}`.trim() || null,
|
||||||
|
toValue: saved.status ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query: {
|
async findAll(query: {
|
||||||
@@ -106,7 +135,25 @@ export class DriversService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.faydaSub && dto.faydaSub !== driver.faydaSub) {
|
||||||
|
const dupe = await this.driverRepo.findOne({
|
||||||
|
where: { faydaSub: dto.faydaSub },
|
||||||
|
});
|
||||||
|
if (dupe) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'A driver is already registered for this Fayda identity',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(driver, dto);
|
Object.assign(driver, dto);
|
||||||
|
|
||||||
|
if (driver.faydaVerified !== true) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Driver identity must be verified with Fayda before saving',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.driverRepo.save(driver);
|
return this.driverRepo.save(driver);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray } from 'class-validator';
|
import { IsString, IsEmail, IsDateString, IsEnum, IsOptional, IsArray, IsBoolean } from 'class-validator';
|
||||||
import { DriverStatus } from '../entities/driver.entity';
|
import { DriverStatus, DriverGender } from '../entities/driver.entity';
|
||||||
|
|
||||||
export class CreateDriverDto {
|
export class CreateDriverDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -20,6 +20,10 @@ export class CreateDriverDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
dateOfBirth!: string;
|
dateOfBirth!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DriverGender)
|
||||||
|
gender?: DriverGender;
|
||||||
|
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
licenseExpiryDate!: string;
|
licenseExpiryDate!: string;
|
||||||
|
|
||||||
@@ -42,4 +46,12 @@ export class CreateDriverDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
faydaSub?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export enum DriverStatus {
|
|||||||
ON_LEAVE = 'ON_LEAVE',
|
ON_LEAVE = 'ON_LEAVE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DriverGender {
|
||||||
|
MALE = 'MALE',
|
||||||
|
FEMALE = 'FEMALE',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ name: 'drivers', schema: 'freight' })
|
@Entity({ name: 'drivers', schema: 'freight' })
|
||||||
export class Driver extends BaseEntity {
|
export class Driver extends BaseEntity {
|
||||||
@Column({ name: 'license_number', unique: true, nullable: true })
|
@Column({ name: 'license_number', unique: true, nullable: true })
|
||||||
@@ -28,6 +34,9 @@ export class Driver extends BaseEntity {
|
|||||||
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
@Column({ name: 'date_of_birth', type: 'date', nullable: true })
|
||||||
dateOfBirth?: Date;
|
dateOfBirth?: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', nullable: true })
|
||||||
|
gender?: DriverGender | null;
|
||||||
|
|
||||||
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
@Column({ name: 'license_expiry_date', type: 'date', nullable: true })
|
||||||
licenseExpiryDate?: Date;
|
licenseExpiryDate?: Date;
|
||||||
|
|
||||||
@@ -51,4 +60,12 @@ export class Driver extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
@Column({ type: 'numeric', precision: 3, scale: 2, nullable: true })
|
||||||
rating?: number | null;
|
rating?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'fayda_verified', type: 'boolean', default: false, nullable: true })
|
||||||
|
faydaVerified?: boolean;
|
||||||
|
|
||||||
|
/** Fayda OIDC subject the identity was verified against. Unique — one driver
|
||||||
|
* record per verified Fayda identity (NULLs allowed for legacy/unverified). */
|
||||||
|
@Column({ name: 'fayda_sub', type: 'varchar', unique: true, nullable: true })
|
||||||
|
faydaSub?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ export class FilesService {
|
|||||||
return this.filesRepository.findByResource(resourceId, resource);
|
return this.filesRepository.findByResource(resourceId, resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||||
|
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||||
|
* callers that expose files for preview/download must sign them first.
|
||||||
|
*/
|
||||||
|
async signUrl(rawUrl: string, expirySeconds = 300): Promise<string> {
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(rawUrl);
|
||||||
|
return this.minioService.getSignedUrl(objectName, expirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
async findByCode(
|
async findByCode(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export class FirstMileContainerAllocationDto {
|
|
||||||
containerId!: string;
|
|
||||||
vehicleId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AllocateFirstMileContainersDto {
|
|
||||||
allocations!: FirstMileContainerAllocationDto[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class VehicleDistanceInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
distanceKm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-vehicle actual distances for a first-mile pickup (multi-truck). */
|
||||||
|
export class SetDistancesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VehicleDistanceInput)
|
||||||
|
distances!: VehicleDistanceInput[];
|
||||||
|
|
||||||
|
/** Recomputed remaining payment (total km × rate), from the client. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
remainingPayment?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class FirstMileVehicleInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
containerNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the full set of vehicles (with their container numbers) on a pickup. */
|
||||||
|
export class SetVehiclesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => FirstMileVehicleInput)
|
||||||
|
vehicles!: FirstMileVehicleInput[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||||
|
|
||||||
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { FirstMile } from './first-mile.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per vehicle assigned to a first-mile pickup. A pickup can be served
|
||||||
|
* by several vehicles at once (multi-truck bookings); the legacy
|
||||||
|
* `first_mile.vehicle_id` column keeps pointing at the first assignment for
|
||||||
|
* backward compatibility.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'first_mile_vehicle_assignments', schema: 'freight' })
|
||||||
|
@Unique(['firstMileId', 'vehicleId'])
|
||||||
|
@Index(['vehicleId'])
|
||||||
|
export class FirstMileVehicleAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'first_mile_id', type: 'uuid' })
|
||||||
|
firstMileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => FirstMile, (fm) => fm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'first_mile_id' })
|
||||||
|
firstMile?: FirstMile;
|
||||||
|
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
|
/** Container this truck carries — auto-filled from the booking's container
|
||||||
|
* number when known, else entered manually at assignment time. */
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
|
distanceKm?: number | null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
|||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from './first-mile-container-allocation.entity';
|
||||||
|
import { FirstMileVehicleAssignment } from './first-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
export const FIRST_MILE_STATUSES = [
|
export const FIRST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -61,4 +62,7 @@ export class FirstMile extends BaseEntity {
|
|||||||
{ eager: false },
|
{ eager: false },
|
||||||
)
|
)
|
||||||
containerAllocations!: FirstMileContainerAllocation[];
|
containerAllocations!: FirstMileContainerAllocation[];
|
||||||
|
|
||||||
|
@OneToMany(() => FirstMileVehicleAssignment, (va) => va.firstMile)
|
||||||
|
vehicleAssignments?: FirstMileVehicleAssignment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ export class FirstMileInvoiceService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalAmount = record.remainingPayment || 0;
|
// numeric columns come back as strings — coerce before the finite/>0 check.
|
||||||
|
const totalAmount = Number(record.remainingPayment) || 0;
|
||||||
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
|
`Skipping invoice for first-mile record ${record.id}: no remaining payment.`,
|
||||||
@@ -71,7 +72,7 @@ export class FirstMileInvoiceService {
|
|||||||
type: 'DELIVERY_FEE',
|
type: 'DELIVERY_FEE',
|
||||||
companyId: fm.booking!.companyId,
|
companyId: fm.booking!.companyId,
|
||||||
companyProfileId: fm.booking!.companyProfileId || '',
|
companyProfileId: fm.booking!.companyProfileId || '',
|
||||||
currency: 'ETB',
|
currency: fm.booking!.paymentCurrency || 'ETB',
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'DELIVERY',
|
chargeType: 'DELIVERY',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { FirstMileStatus } from './entities/first-mile.entity';
|
import { FirstMileStatus } from './entities/first-mile.entity';
|
||||||
import { FirstMileService } from './first-mile.service';
|
import { FirstMileService } from './first-mile.service';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
import { BillingService } from '../billing/billing.service';
|
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
|
|
||||||
@ApiTags('first-mile')
|
@ApiTags('first-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -33,8 +32,6 @@ export class FirstMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly firstMileService: FirstMileService,
|
private readonly firstMileService: FirstMileService,
|
||||||
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
private readonly firstMileInvoiceService: FirstMileInvoiceService,
|
||||||
private readonly billingService: BillingService,
|
|
||||||
private readonly bookingsService: BookingsService
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -89,39 +86,43 @@ export class FirstMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a first-mile leg' })
|
@ApiOperation({ summary: 'Update a first-mile leg' })
|
||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
|
||||||
const record = await this.firstMileService.update(id, dto);
|
// No invoice side-effects — invoices are generated only via the explicit
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// POST :id/invoice endpoint (the "Generate Invoice" action).
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
return this.firstMileService.update(id, dto);
|
||||||
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
}
|
||||||
await this.billingService.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.FirstMile,
|
|
||||||
sourceId: record.id,
|
|
||||||
type: "FIRST_MILE",
|
|
||||||
companyId: booking.companyId,
|
|
||||||
companyProfileId: booking.companyProfileId,
|
|
||||||
currency: "ETB",
|
|
||||||
|
|
||||||
lines: [
|
@Post(':id/invoice')
|
||||||
{
|
@TrainSchedulingManage()
|
||||||
chargeType: "FIRST_MILE",
|
@ApiOperation({ summary: 'Generate the first-mile delivery-fee invoice' })
|
||||||
description: "First Mile Transportation Service",
|
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
quantity: 1,
|
const record = await this.firstMileService.findById(id);
|
||||||
unitRate: record.remainingPayment,
|
const invoice = await this.firstMileInvoiceService.ensureInvoiceFor(record);
|
||||||
amount: record.remainingPayment,
|
if (!invoice) {
|
||||||
currency: "ETB",
|
throw new BadRequestException(
|
||||||
},
|
'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a FIRST_MILE rate is configured, and the booking has a company.',
|
||||||
],
|
);
|
||||||
|
|
||||||
subtotalAmount: record.remainingPayment,
|
|
||||||
taxAmount: 0, // Replace if VAT/tax applies
|
|
||||||
totalAmount: record.remainingPayment,
|
|
||||||
|
|
||||||
dueInDays: 7,
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
await this.firstMileInvoiceService.ensureInvoiceFor(record);
|
|
||||||
}
|
}
|
||||||
return record;
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/vehicles')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set the vehicles assigned to a first-mile pickup (multi-truck)' })
|
||||||
|
async setVehicles(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetVehiclesDto,
|
||||||
|
) {
|
||||||
|
return this.firstMileService.setVehicles(id, dto.vehicles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/distances')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||||
|
async setDistances(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetDistancesDto,
|
||||||
|
) {
|
||||||
|
return this.firstMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -131,14 +132,4 @@ export class FirstMileController {
|
|||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.firstMileService.remove(id);
|
return this.firstMileService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':firstMileId/allocate-containers')
|
|
||||||
@TrainSchedulingManage()
|
|
||||||
@ApiOperation({ summary: 'Allocate containers to vehicles for a first-mile leg' })
|
|
||||||
allocateContainers(
|
|
||||||
@Param('firstMileId', ParseUUIDPipe) firstMileId: string,
|
|
||||||
@Body() dto: AllocateFirstMileContainersDto,
|
|
||||||
) {
|
|
||||||
return this.firstMileService.allocateContainers(firstMileId, dto.allocations);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { FirstMile } from './entities/first-mile.entity';
|
import { FirstMile } from './entities/first-mile.entity';
|
||||||
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
|
||||||
|
import { FirstMileVehicleAssignment } from './entities/first-mile-vehicle-assignment.entity';
|
||||||
import { FirstMileController } from './first-mile.controller';
|
import { FirstMileController } from './first-mile.controller';
|
||||||
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
import { FirstMileInvoiceService } from './first-mile-invoice.service';
|
||||||
import { FirstMileRepository } from './first-mile.repository';
|
import { FirstMileRepository } from './first-mile.repository';
|
||||||
@@ -15,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
|
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation, FirstMileVehicleAssignment]),
|
||||||
forwardRef(() => BillingModule),
|
forwardRef(() => BillingModule),
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { FindOptionsWhere, In } from 'typeorm';
|
import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
@@ -11,9 +11,12 @@ import { CreateFirstMileDto } from "./dto/create-first-mile.dto";
|
|||||||
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
import { UpdateFirstMileDto } from "./dto/update-first-mile.dto";
|
||||||
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
import { FirstMile, FirstMileStatus } from "./entities/first-mile.entity";
|
||||||
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
import { FirstMileContainerAllocation } from "./entities/first-mile-container-allocation.entity";
|
||||||
|
import { FirstMileVehicleAssignment } from "./entities/first-mile-vehicle-assignment.entity";
|
||||||
import { FirstMileRepository } from "./first-mile.repository";
|
import { FirstMileRepository } from "./first-mile.repository";
|
||||||
import { OnEvent } from "@nestjs/event-emitter";
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
import { InvoiceEventPayload } from "../billing/billing.service";
|
import { BillingService, InvoiceEventPayload } from "../billing/billing.service";
|
||||||
|
import { FleetHistoryService } from "../fleet-history/fleet-history.service";
|
||||||
|
import { FleetEventType } from "../fleet-history/entities/fleet-event.entity";
|
||||||
|
|
||||||
type FirstMileListFilter = {
|
type FirstMileListFilter = {
|
||||||
status?: FirstMileStatus;
|
status?: FirstMileStatus;
|
||||||
@@ -43,8 +46,78 @@ export class FirstMileService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
/** Attach real invoice info so the UI shows an invoice link only when one
|
||||||
|
* exists — not merely because distance was entered. Batched (no N+1). */
|
||||||
|
private async attachInvoices(records: FirstMile[]): Promise<void> {
|
||||||
|
const invoices = await this.billing.findBySourceIds(
|
||||||
|
'first_mile',
|
||||||
|
records.map((r) => r.id),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, { id: string; number: string; status: string }>();
|
||||||
|
for (const inv of invoices) {
|
||||||
|
if (!byId.has(inv.sourceId)) {
|
||||||
|
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of records) {
|
||||||
|
(r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
|
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
||||||
|
private async vehicleInfo(
|
||||||
|
vehicleId?: string | null,
|
||||||
|
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
|
||||||
|
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
|
||||||
|
try {
|
||||||
|
const v = await this.vehiclesService.findById(vehicleId);
|
||||||
|
return {
|
||||||
|
driverId: v.assignedDriverId ?? null,
|
||||||
|
plate: v.plateNumber ?? v.code ?? null,
|
||||||
|
driverName: v.assignedDriverName ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { driverId: null, plate: null, driverName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A leg counts as having a vehicle if it has a direct assignment or at least
|
||||||
|
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
|
||||||
|
private async hasAssignedVehicle(
|
||||||
|
recordId: string,
|
||||||
|
directVehicleId?: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (directVehicleId) return true;
|
||||||
|
const [junction, allocations] = await Promise.all([
|
||||||
|
this.dataSource.manager.count(FirstMileVehicleAssignment, {
|
||||||
|
where: { firstMileId: recordId },
|
||||||
|
}),
|
||||||
|
this.dataSource.manager.count(FirstMileContainerAllocation, {
|
||||||
|
where: { firstMileId: recordId, vehicleId: Not(IsNull()) },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return junction > 0 || allocations > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human booking reference for a first-mile record, for the history timeline. */
|
||||||
|
private async resolveBookingRef(record: FirstMile): Promise<string | null> {
|
||||||
|
const loaded = (record as FirstMile & { booking?: { reference?: string } })
|
||||||
|
.booking?.reference;
|
||||||
|
if (loaded) return loaded;
|
||||||
|
if (!record.bookingId) return null;
|
||||||
|
try {
|
||||||
|
const b = await this.bookingsRepository.findById(record.bookingId);
|
||||||
|
return (b as { reference?: string } | null)?.reference ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Look up a booking by its human-readable reference and confirm it has been
|
* Look up a booking by its human-readable reference and confirm it has been
|
||||||
* paid before any first-mile work proceeds. Throws if the reference is
|
* paid before any first-mile work proceeds. Throws if the reference is
|
||||||
@@ -135,14 +208,18 @@ export class FirstMileService {
|
|||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
cargoType: true,
|
cargoType: true,
|
||||||
|
bookingContainers: { containerType: true, units: true },
|
||||||
},
|
},
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.attachInvoices(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -179,8 +256,10 @@ export class FirstMileService {
|
|||||||
originYard: true,
|
originYard: true,
|
||||||
destinationYard: true,
|
destinationYard: true,
|
||||||
cargoType: true,
|
cargoType: true,
|
||||||
|
bookingContainers: { containerType: true, units: true },
|
||||||
},
|
},
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +267,8 @@ export class FirstMileService {
|
|||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.attachInvoices([record]);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +291,20 @@ export class FirstMileService {
|
|||||||
|
|
||||||
if (dto.vehicleId) {
|
if (dto.vehicleId) {
|
||||||
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||||
|
const info = await this.vehicleInfo(dto.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
firstMileId: record.id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: record.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'FIRST',
|
||||||
|
bookingRef: await this.resolveBookingRef(record),
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
@@ -248,6 +343,18 @@ export class FirstMileService {
|
|||||||
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
|
||||||
|
// assigned in this same request).
|
||||||
|
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
|
||||||
|
const vehicleId =
|
||||||
|
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
|
||||||
|
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Assign a vehicle before marking this first-mile leg in transit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dtoAny = dto as any;
|
const dtoAny = dto as any;
|
||||||
const updated = await this.firstMileRepository.update(id, {
|
const updated = await this.firstMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -278,6 +385,39 @@ export class FirstMileService {
|
|||||||
if (existing.vehicleId) {
|
if (existing.vehicleId) {
|
||||||
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
||||||
}
|
}
|
||||||
|
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
if (existing.vehicleId) {
|
||||||
|
const info = await this.vehicleInfo(existing.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId: existing.vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: {
|
||||||
|
mile: 'FIRST',
|
||||||
|
bookingRef,
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
const info = await this.vehicleInfo(dto.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: updated.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'FIRST',
|
||||||
|
bookingRef,
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
// Notify assigned driver on every explicit vehicle assignment or reassignment
|
||||||
@@ -285,6 +425,25 @@ export class FirstMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.status !== undefined && dto.status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: info.driverId,
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: dto.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'FIRST',
|
||||||
|
bookingRef: await this.resolveBookingRef(existing),
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trip finished — release the vehicles it was holding
|
// Trip finished — release the vehicles it was holding
|
||||||
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
@@ -295,12 +454,40 @@ export class FirstMileService {
|
|||||||
|
|
||||||
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
if (status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
|
||||||
|
if (!(await this.hasAssignedVehicle(id, existing.vehicleId))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Assign a vehicle before marking this first-mile leg in transit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.firstMileRepository.update(id, { status });
|
const updated = await this.firstMileRepository.update(id, { status });
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new NotFoundException(`First-mile record ${id} not found`);
|
throw new NotFoundException(`First-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: info.driverId,
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'FIRST',
|
||||||
|
bookingRef: await this.resolveBookingRef(existing),
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
|
||||||
await this.releaseVehicles(updated);
|
await this.releaseVehicles(updated);
|
||||||
}
|
}
|
||||||
@@ -313,18 +500,154 @@ export class FirstMileService {
|
|||||||
* allocations), unless still in use by another active trip.
|
* allocations), unless still in use by another active trip.
|
||||||
*/
|
*/
|
||||||
private async releaseVehicles(record: FirstMile): Promise<void> {
|
private async releaseVehicles(record: FirstMile): Promise<void> {
|
||||||
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
|
const [assignments, recordAllocations] = await Promise.all([
|
||||||
where: { firstMileId: record.id },
|
this.dataSource.manager.find(FirstMileVehicleAssignment, {
|
||||||
});
|
where: { firstMileId: record.id },
|
||||||
const vehicleIds = recordAllocations
|
}),
|
||||||
.map((a) => a.vehicleId)
|
this.dataSource.manager.find(FirstMileContainerAllocation, {
|
||||||
.filter((id): id is string => Boolean(id));
|
where: { firstMileId: record.id },
|
||||||
if (record.vehicleId) {
|
}),
|
||||||
vehicleIds.push(record.vehicleId);
|
]);
|
||||||
}
|
const vehicleIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...recordAllocations.map((a) => a.vehicleId),
|
||||||
|
record.vehicleId ?? null,
|
||||||
|
].filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full set of vehicles serving a first-mile pickup (multi-truck).
|
||||||
|
* Diffs against the current junction rows, syncing availability + audit history
|
||||||
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
|
*/
|
||||||
|
async setVehicles(
|
||||||
|
id: string,
|
||||||
|
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
): Promise<FirstMile> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||||
|
const desiredMap = new Map<string, string | null>();
|
||||||
|
for (const inp of inputs) {
|
||||||
|
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||||
|
}
|
||||||
|
const desired = [...desiredMap.keys()];
|
||||||
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
const manager = this.dataSource.manager;
|
||||||
|
const current = await manager.find(FirstMileVehicleAssignment, {
|
||||||
|
where: { firstMileId: id },
|
||||||
|
});
|
||||||
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
|
// old single-vehicle path has no junction row but must still be freed.
|
||||||
|
const releaseIds = [...new Set(
|
||||||
|
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
|
||||||
|
)];
|
||||||
|
const added = desired.filter((v) => !junctionSet.has(v));
|
||||||
|
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||||
|
// Vehicles that stay but whose container number changed.
|
||||||
|
const changed = current.filter(
|
||||||
|
(a) =>
|
||||||
|
desiredMap.has(a.vehicleId) &&
|
||||||
|
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (tx) => {
|
||||||
|
if (removed.length) {
|
||||||
|
await tx.delete(FirstMileVehicleAssignment, {
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId: In(removed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await tx.insert(FirstMileVehicleAssignment, {
|
||||||
|
firstMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of changed) {
|
||||||
|
await tx.update(
|
||||||
|
FirstMileVehicleAssignment,
|
||||||
|
{ firstMileId: id, vehicleId: row.vehicleId },
|
||||||
|
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy primary vehicle = first of the set (null when cleared).
|
||||||
|
await this.firstMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
|
||||||
|
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
|
||||||
|
void this.notifyDriverAssignment(vehicleId, existing);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: existing.status,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of removed) {
|
||||||
|
await this.vehiclesService.releaseIfUnused([vehicleId]);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record each truck's actual distance. The pickup total (exact_km) is their
|
||||||
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
|
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||||
|
*/
|
||||||
|
async setDistances(
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
): Promise<FirstMile> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
// Distances are locked once the invoice exists.
|
||||||
|
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Distances cannot be changed after the invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of distances) {
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
FirstMileVehicleAssignment,
|
||||||
|
{ firstMileId: id, vehicleId: d.vehicleId },
|
||||||
|
{ distanceKm: d.distanceKm },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
|
||||||
|
await this.firstMileRepository.update(id, {
|
||||||
|
exactKm: total,
|
||||||
|
...(remainingPayment != null ? { remainingPayment } : {}),
|
||||||
|
} as any);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
@@ -383,56 +706,54 @@ export class FirstMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
await this.firstMileRepository.softDelete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async allocateContainers(
|
// Can't delete once billed.
|
||||||
firstMileId: string,
|
const invoices = await this.billing.findBySourceIds('first_mile', [id]);
|
||||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
if (invoices.length) {
|
||||||
) {
|
throw new BadRequestException(
|
||||||
const firstMile = await this.findById(firstMileId);
|
'Cannot delete a first-mile leg after its invoice is generated',
|
||||||
if (!firstMile) {
|
);
|
||||||
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
|
// Every vehicle this pickup holds — junction + legacy + container rows.
|
||||||
where: {
|
const [assignments, allocations] = await Promise.all([
|
||||||
firstMileId,
|
this.dataSource.manager.find(FirstMileVehicleAssignment, {
|
||||||
containerId: In(allocations.map((a) => a.containerId)),
|
where: { firstMileId: id },
|
||||||
},
|
}),
|
||||||
});
|
this.dataSource.manager.find(FirstMileContainerAllocation, {
|
||||||
const previousVehicleIds = previousAllocations
|
where: { firstMileId: id },
|
||||||
.map((a) => a.vehicleId)
|
}),
|
||||||
.filter((id): id is string => Boolean(id));
|
]);
|
||||||
|
const vehicleIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...allocations.map((a) => a.vehicleId),
|
||||||
|
existing.vehicleId ?? null,
|
||||||
|
].filter((v): v is string => Boolean(v)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.firstMileRepository.softDelete(id);
|
||||||
for (const allocation of allocations) {
|
if (assignments.length) {
|
||||||
await manager.delete(FirstMileContainerAllocation, {
|
await this.dataSource.manager.softDelete(FirstMileVehicleAssignment, { firstMileId: id });
|
||||||
firstMileId,
|
}
|
||||||
containerId: allocation.containerId,
|
|
||||||
});
|
// Free every vehicle no longer held by another active trip and audit release.
|
||||||
await manager.insert(FirstMileContainerAllocation, {
|
if (vehicleIds.length) {
|
||||||
firstMileId,
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
containerId: allocation.containerId,
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
vehicleId: allocation.vehicleId,
|
for (const vehicleId of vehicleIds) {
|
||||||
containerType: "CONTAINER",
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
quantity: 1,
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
firstMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'FIRST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
|
|
||||||
await Promise.all(
|
|
||||||
[...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)),
|
|
||||||
);
|
|
||||||
await this.vehiclesService.releaseIfUnused(
|
|
||||||
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
allocated: allocations.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Entity, Column, Index } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only audit log for fleet activity. One row per transition. Queried by
|
||||||
|
* `vehicleId` (vehicle timeline) or `driverId` (driver timeline); an event may
|
||||||
|
* carry both so a driver↔vehicle assignment or a mile assignment shows on both.
|
||||||
|
* `createdAt` (from BaseEntity) is the event time.
|
||||||
|
*/
|
||||||
|
export enum FleetEventType {
|
||||||
|
DRIVER_REGISTERED = 'DRIVER_REGISTERED',
|
||||||
|
VEHICLE_REGISTERED = 'VEHICLE_REGISTERED',
|
||||||
|
DRIVER_ASSIGNED = 'DRIVER_ASSIGNED',
|
||||||
|
DRIVER_UNASSIGNED = 'DRIVER_UNASSIGNED',
|
||||||
|
VEHICLE_STATUS_CHANGED = 'VEHICLE_STATUS_CHANGED',
|
||||||
|
VEHICLE_AVAILABILITY_CHANGED = 'VEHICLE_AVAILABILITY_CHANGED',
|
||||||
|
MILE_VEHICLE_ASSIGNED = 'MILE_VEHICLE_ASSIGNED',
|
||||||
|
MILE_VEHICLE_RELEASED = 'MILE_VEHICLE_RELEASED',
|
||||||
|
MILE_STATUS_CHANGED = 'MILE_STATUS_CHANGED',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'fleet_events', schema: 'freight' })
|
||||||
|
export class FleetEvent extends BaseEntity {
|
||||||
|
@Column({ name: 'event_type', type: 'varchar' })
|
||||||
|
eventType!: FleetEventType;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
|
||||||
|
vehicleId?: string | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
|
||||||
|
driverId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'first_mile_id', type: 'uuid', nullable: true })
|
||||||
|
firstMileId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'last_mile_id', type: 'uuid', nullable: true })
|
||||||
|
lastMileId?: string | null;
|
||||||
|
|
||||||
|
/** Previous value for a transition (e.g. old status/availability). */
|
||||||
|
@Column({ name: 'from_value', type: 'varchar', nullable: true })
|
||||||
|
fromValue?: string | null;
|
||||||
|
|
||||||
|
/** New value for a transition (e.g. new status/availability). */
|
||||||
|
@Column({ name: 'to_value', type: 'varchar', nullable: true })
|
||||||
|
toValue?: string | null;
|
||||||
|
|
||||||
|
/** Human-readable summary token (driver name, plate, booking ref, mile). */
|
||||||
|
@Column({ name: 'label', type: 'varchar', nullable: true })
|
||||||
|
label?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FleetEvent } from './entities/fleet-event.entity';
|
||||||
|
import { FleetHistoryService } from './fleet-history.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global so any fleet-touching service (vehicles, drivers, first/last-mile) can
|
||||||
|
* inject FleetHistoryService to append audit events without each module having
|
||||||
|
* to import this one.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([FleetEvent])],
|
||||||
|
providers: [FleetHistoryService],
|
||||||
|
exports: [FleetHistoryService],
|
||||||
|
})
|
||||||
|
export class FleetHistoryModule {}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { FleetEvent, FleetEventType } from './entities/fleet-event.entity';
|
||||||
|
|
||||||
|
export interface FleetEventInput {
|
||||||
|
eventType: FleetEventType;
|
||||||
|
vehicleId?: string | null;
|
||||||
|
driverId?: string | null;
|
||||||
|
firstMileId?: string | null;
|
||||||
|
lastMileId?: string | null;
|
||||||
|
fromValue?: string | null;
|
||||||
|
toValue?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FleetHistoryService {
|
||||||
|
private readonly logger = new Logger(FleetHistoryService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FleetEvent)
|
||||||
|
private readonly eventRepo: Repository<FleetEvent>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append an audit event. Best-effort: recording history must never break the
|
||||||
|
* business operation that triggered it, so failures are logged and swallowed.
|
||||||
|
*/
|
||||||
|
async record(input: FleetEventInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.eventRepo.save(this.eventRepo.create(input));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to record fleet event ${input.eventType}: ${String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getVehicleHistory(vehicleId: string): Promise<FleetEvent[]> {
|
||||||
|
return this.eventRepo.find({
|
||||||
|
where: { vehicleId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDriverHistory(driverId: string): Promise<FleetEvent[]> {
|
||||||
|
return this.eventRepo.find({
|
||||||
|
where: { driverId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export class LastMileContainerAllocationDto {
|
|
||||||
containerId!: string;
|
|
||||||
vehicleId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AllocateLastMileContainersDto {
|
|
||||||
allocations!: LastMileContainerAllocationDto[];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsArray, IsNumber, IsOptional, IsUUID, Min, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class VehicleDistanceInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
distanceKm!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-vehicle actual distances for a last-mile delivery (multi-truck). */
|
||||||
|
export class SetDistancesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => VehicleDistanceInput)
|
||||||
|
distances!: VehicleDistanceInput[];
|
||||||
|
|
||||||
|
/** Recomputed remaining payment (total km × rate), from the client. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
remainingPayment?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class LastMileVehicleInput {
|
||||||
|
@IsUUID()
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
containerNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||||
|
export class SetVehiclesDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => LastMileVehicleInput)
|
||||||
|
vehicles!: LastMileVehicleInput[];
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export class LastMileContainerAllocation extends BaseEntity {
|
|||||||
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
@Column('uuid', { name: 'vehicle_id', nullable: true })
|
||||||
vehicleId?: string | null;
|
vehicleId?: string | null;
|
||||||
|
|
||||||
@Column('text')
|
@Column('text', { name: 'container_type' })
|
||||||
containerType!: string;
|
containerType!: string;
|
||||||
|
|
||||||
@Column('integer', { default: 1 })
|
@Column('integer', { default: 1 })
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||||
|
|
||||||
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
|
import { LastMile } from './last-mile.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||||
|
* served by several vehicles at once (multi-truck bookings); the legacy
|
||||||
|
* `last_mile.vehicle_id` column keeps pointing at the first assignment for
|
||||||
|
* backward compatibility.
|
||||||
|
*/
|
||||||
|
@Entity({ name: 'last_mile_vehicle_assignments', schema: 'freight' })
|
||||||
|
@Unique(['lastMileId', 'vehicleId'])
|
||||||
|
@Index(['vehicleId'])
|
||||||
|
export class LastMileVehicleAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||||
|
lastMileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => LastMile, (lm) => lm.vehicleAssignments, { nullable: false, onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'last_mile_id' })
|
||||||
|
lastMile?: LastMile;
|
||||||
|
|
||||||
|
@Column({ name: 'vehicle_id', type: 'uuid' })
|
||||||
|
vehicleId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Vehicle, { nullable: false, eager: false })
|
||||||
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
|
/** Container this truck carries — auto-filled from the booking's container
|
||||||
|
* number when known, else entered manually at assignment time. */
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
|
distanceKm?: number | null;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
|
|||||||
import { Booking } from '../../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './last-mile-container-allocation.entity';
|
||||||
|
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
export const LAST_MILE_STATUSES = [
|
export const LAST_MILE_STATUSES = [
|
||||||
'PAYMENT_PENDING',
|
'PAYMENT_PENDING',
|
||||||
@@ -57,4 +58,7 @@ export class LastMile extends BaseEntity {
|
|||||||
|
|
||||||
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
@OneToMany(() => LastMileContainerAllocation, (ca) => ca.lastMile)
|
||||||
containerAllocations?: LastMileContainerAllocation[];
|
containerAllocations?: LastMileContainerAllocation[];
|
||||||
|
|
||||||
|
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||||
|
vehicleAssignments?: LastMileVehicleAssignment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export class LastMileInvoiceService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// numeric columns come back as strings — coerce before billing.
|
||||||
|
const totalAmount = Number(record.remainingPayment) || 0;
|
||||||
|
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for last-mile record ${record.id}: no remaining payment.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate invoice with remainingPayment as totalAmount
|
// Generate invoice with remainingPayment as totalAmount
|
||||||
const input: GenerateInvoiceInput = {
|
const input: GenerateInvoiceInput = {
|
||||||
source: 'last_mile' as Freight.InvoiceSource,
|
source: 'last_mile' as Freight.InvoiceSource,
|
||||||
@@ -61,17 +70,17 @@ export class LastMileInvoiceService {
|
|||||||
type: 'DELIVERY_FEE',
|
type: 'DELIVERY_FEE',
|
||||||
companyId: lm.booking!.companyId,
|
companyId: lm.booking!.companyId,
|
||||||
companyProfileId: lm.booking!.companyProfileId || '',
|
companyProfileId: lm.booking!.companyProfileId || '',
|
||||||
currency: 'ETB',
|
currency: lm.booking!.paymentCurrency || 'ETB',
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'DELIVERY',
|
chargeType: 'DELIVERY',
|
||||||
description: 'Last-mile delivery',
|
description: 'Last-mile delivery',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitRate: record.remainingPayment || 0,
|
unitRate: totalAmount,
|
||||||
amount: record.remainingPayment || 0,
|
amount: totalAmount,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
totalAmount: record.remainingPayment || 0,
|
totalAmount,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.billing.generateInvoice(input);
|
return this.billing.generateInvoice(input);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -17,13 +18,11 @@ import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking
|
|||||||
|
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { Freight } from '@edr/types';
|
|
||||||
import { BillingService } from '../billing/billing.service';
|
|
||||||
import { BookingsService } from '../bookings/bookings.service';
|
|
||||||
|
|
||||||
@ApiTags('last-mile')
|
@ApiTags('last-mile')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -33,8 +32,6 @@ export class LastMileController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
private readonly lastMileInvoiceService: LastMileInvoiceService,
|
||||||
private readonly billingService: BillingService,
|
|
||||||
private readonly bookingsService: BookingsService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -83,39 +80,9 @@ export class LastMileController {
|
|||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Update a last-mile leg' })
|
@ApiOperation({ summary: 'Update a last-mile leg' })
|
||||||
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
|
||||||
const record = await this.lastMileService.update(id, dto);
|
// No invoice side-effects here — invoices are generated only via the
|
||||||
// Auto-generate invoice if distance or payment was updated
|
// explicit POST :id/invoice endpoint (the "Generate Invoice" action).
|
||||||
const booking = await this.bookingsService.findById(record.bookingId);
|
return this.lastMileService.update(id, dto);
|
||||||
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
|
|
||||||
await this.billingService.generateInvoice({
|
|
||||||
source: Freight.InvoiceSource.LastMile,
|
|
||||||
sourceId: record.id,
|
|
||||||
type: "LAST_MILE",
|
|
||||||
companyId: booking.companyId,
|
|
||||||
companyProfileId: booking.companyProfileId,
|
|
||||||
currency: "ETB",
|
|
||||||
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
chargeType: "LAST_MILE",
|
|
||||||
description: "Last Mile Transportation Service",
|
|
||||||
quantity: 1,
|
|
||||||
unitRate: record.remainingPayment,
|
|
||||||
amount: record.remainingPayment,
|
|
||||||
currency: "ETB",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
subtotalAmount: record.remainingPayment,
|
|
||||||
taxAmount: 0, // Replace if VAT/tax applies
|
|
||||||
totalAmount: record.remainingPayment,
|
|
||||||
|
|
||||||
dueInDays: 7,
|
|
||||||
status: Freight.InvoiceStatus.Pending,
|
|
||||||
});
|
|
||||||
await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@@ -126,13 +93,38 @@ export class LastMileController {
|
|||||||
return this.lastMileService.remove(id);
|
return this.lastMileService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/allocate-containers')
|
|
||||||
|
@Post(':id/vehicles')
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({ summary: 'Allocate containers to vehicles' })
|
@ApiOperation({ summary: 'Set the vehicles assigned to a last-mile delivery (multi-truck)' })
|
||||||
async allocateContainers(
|
async setVehicles(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AllocateLastMileContainersDto,
|
@Body() dto: SetVehiclesDto,
|
||||||
) {
|
) {
|
||||||
return this.lastMileService.allocateContainers(id, dto.allocations);
|
return this.lastMileService.setVehicles(id, dto.vehicles);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/distances')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Set per-vehicle actual distances (does not generate an invoice)' })
|
||||||
|
async setDistances(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SetDistancesDto,
|
||||||
|
) {
|
||||||
|
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/invoice')
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||||
|
async generateInvoice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const record = await this.lastMileService.findById(id);
|
||||||
|
const invoice = await this.lastMileInvoiceService.ensureInvoiceFor(record);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot generate invoice: the leg has no billable amount. Add distance and ensure a LAST_MILE rate is configured, and the booking has a company.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
@@ -15,7 +16,7 @@ import { LastMileService } from './last-mile.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation]),
|
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
|
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';
|
||||||
|
|
||||||
type LastMileListFilter = {
|
type LastMileListFilter = {
|
||||||
status?: LastMileStatus;
|
status?: LastMileStatus;
|
||||||
@@ -41,8 +45,77 @@ export class LastMileService {
|
|||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly smsClient: SmsClientService,
|
private readonly smsClient: SmsClientService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly history: FleetHistoryService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Attach real invoice info (number/status) to records so the UI can show an
|
||||||
|
* invoice link only when one actually exists — NOT merely because distance
|
||||||
|
* was entered. Batched to avoid N+1. */
|
||||||
|
private async attachInvoices(records: LastMile[]): Promise<void> {
|
||||||
|
const invoices = await this.billing.findBySourceIds(
|
||||||
|
'last_mile',
|
||||||
|
records.map((r) => r.id),
|
||||||
|
);
|
||||||
|
const byId = new Map<string, { id: string; number: string; status: string }>();
|
||||||
|
for (const inv of invoices) {
|
||||||
|
if (!byId.has(inv.sourceId)) {
|
||||||
|
byId.set(inv.sourceId, { id: inv.id, number: inv.invoiceNumber, status: String(inv.status) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of records) {
|
||||||
|
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
|
||||||
|
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
|
||||||
|
private async vehicleInfo(
|
||||||
|
vehicleId?: string | null,
|
||||||
|
): Promise<{ driverId: string | null; plate: string | null; driverName: string | null }> {
|
||||||
|
if (!vehicleId) return { driverId: null, plate: null, driverName: null };
|
||||||
|
try {
|
||||||
|
const v = await this.vehiclesService.findById(vehicleId);
|
||||||
|
return {
|
||||||
|
driverId: v.assignedDriverId ?? null,
|
||||||
|
plate: v.plateNumber ?? v.code ?? null,
|
||||||
|
driverName: v.assignedDriverName ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { driverId: null, plate: null, driverName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A leg counts as having a vehicle if it has a direct assignment or at least
|
||||||
|
* one container allocation carrying a vehicle. Gates the IN_TRANSIT move. */
|
||||||
|
private async hasAssignedVehicle(
|
||||||
|
recordId: string,
|
||||||
|
directVehicleId?: string | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (directVehicleId) return true;
|
||||||
|
const count = await this.dataSource.manager.count(LastMileContainerAllocation, {
|
||||||
|
where: { lastMileId: recordId, vehicleId: Not(IsNull()) },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human booking reference for a last-mile record, for the history timeline.
|
||||||
|
* Uses the already-loaded relation when present, else looks it up. */
|
||||||
|
private async resolveBookingRef(
|
||||||
|
record: LastMile,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const loaded = (record as LastMile & { booking?: { reference?: string } })
|
||||||
|
.booking?.reference;
|
||||||
|
if (loaded) return loaded;
|
||||||
|
if (!record.bookingId) return null;
|
||||||
|
try {
|
||||||
|
const booking = await this.bookingsRepository.findById(record.bookingId);
|
||||||
|
return (booking as { reference?: string } | null)?.reference ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
const booking = await this.bookingsRepository.findByReference(bookingReference);
|
||||||
|
|
||||||
@@ -97,14 +170,17 @@ export class LastMileService {
|
|||||||
const [data, total] = await this.lastMileRepository.findAndCount({
|
const [data, total] = await this.lastMileRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.attachInvoices(data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
@@ -119,8 +195,9 @@ export class LastMileService {
|
|||||||
async findById(id: string): Promise<LastMile> {
|
async findById(id: string): Promise<LastMile> {
|
||||||
const record = await this.lastMileRepository.findById(id, {
|
const record = await this.lastMileRepository.findById(id, {
|
||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
|
vehicleAssignments: { vehicle: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,11 +205,13 @@ export class LastMileService {
|
|||||||
throw new NotFoundException(`Last-mile record ${id} not found`);
|
throw new NotFoundException(`Last-mile record ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.attachInvoices([record]);
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
return this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
status: dto.status ?? 'READY_TO_TRANSIT',
|
status: dto.status ?? 'READY_TO_TRANSIT',
|
||||||
advancedPayment: dto.advancedPayment ?? 0,
|
advancedPayment: dto.advancedPayment ?? 0,
|
||||||
@@ -142,16 +221,38 @@ export class LastMileService {
|
|||||||
vehicleId: dto.vehicleId ?? null,
|
vehicleId: dto.vehicleId ?? null,
|
||||||
paid: (dto as any).paid ?? false,
|
paid: (dto as any).paid ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||||
|
const info = await this.vehicleInfo(dto.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
lastMileId: record.id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: record.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'LAST',
|
||||||
|
bookingRef: await this.resolveBookingRef(record),
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent("lastmile.invoice.paid")
|
@OnEvent("last_mile.invoice.paid")
|
||||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
|
// Invoice paid → the delivery is complete. Route through update() so it
|
||||||
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
|
// also frees the trucks + records history (same as "Mark Delivered").
|
||||||
|
await this.update(payload.sourceId, { status: 'DELIVERED', paid: true } as unknown as UpdateLastMileDto);
|
||||||
|
this.logger.log(`Last-mile ${payload.sourceId} marked DELIVERED on invoice ${payload.invoiceId} payment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
|
`Failed to deliver last-mile ${payload.sourceId} on payment: ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +260,18 @@ export class LastMileService {
|
|||||||
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
|
||||||
|
// assigned in this same request).
|
||||||
|
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {
|
||||||
|
const vehicleId =
|
||||||
|
dto.vehicleId !== undefined ? dto.vehicleId : existing.vehicleId;
|
||||||
|
if (!(await this.hasAssignedVehicle(id, vehicleId))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Assign a vehicle before marking this last-mile leg in transit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dtoAny = dto as any;
|
const dtoAny = dto as any;
|
||||||
const updated = await this.lastMileRepository.update(id, {
|
const updated = await this.lastMileRepository.update(id, {
|
||||||
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
|
||||||
@@ -180,9 +293,231 @@ export class LastMileService {
|
|||||||
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
void this.notifyDriverAssignment(dto.vehicleId, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audit the mile↔vehicle (re)assignment on both vehicle and driver lines.
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
|
||||||
|
// Keep vehicle availability in sync: new vehicle goes BUSY, replaced one
|
||||||
|
// is freed if no other active trip still holds it.
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
|
||||||
|
}
|
||||||
|
if (existing.vehicleId) {
|
||||||
|
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
|
||||||
|
}
|
||||||
|
if (existing.vehicleId) {
|
||||||
|
const info = await this.vehicleInfo(existing.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId: existing.vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: {
|
||||||
|
mile: 'LAST',
|
||||||
|
bookingRef,
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dto.vehicleId) {
|
||||||
|
const info = await this.vehicleInfo(dto.vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId: dto.vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: updated.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'LAST',
|
||||||
|
bookingRef,
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.status !== undefined && dto.status !== existing.status) {
|
||||||
|
const vehicleId = updated.vehicleId ?? existing.vehicleId ?? null;
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_STATUS_CHANGED,
|
||||||
|
lastMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
driverId: info.driverId,
|
||||||
|
fromValue: existing.status,
|
||||||
|
toValue: dto.status,
|
||||||
|
metadata: {
|
||||||
|
mile: 'LAST',
|
||||||
|
bookingRef,
|
||||||
|
vehiclePlate: info.plate,
|
||||||
|
driverName: info.driverName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery finished — free the vehicles this trip was holding.
|
||||||
|
if (dto.status === 'DELIVERED' && existing.status !== 'DELIVERED') {
|
||||||
|
await this.releaseVehicles(updated);
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free every vehicle held by this record — junction assignments, the legacy
|
||||||
|
* direct vehicle, and container allocations — unless still used by another
|
||||||
|
* active trip.
|
||||||
|
*/
|
||||||
|
private async releaseVehicles(record: LastMile): Promise<void> {
|
||||||
|
const [assignments, recordAllocations] = await Promise.all([
|
||||||
|
this.dataSource.manager.find(LastMileVehicleAssignment, {
|
||||||
|
where: { lastMileId: record.id },
|
||||||
|
}),
|
||||||
|
this.dataSource.manager.find(LastMileContainerAllocation, {
|
||||||
|
where: { lastMileId: record.id },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const vehicleIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...assignments.map((a) => a.vehicleId),
|
||||||
|
...recordAllocations.map((a) => a.vehicleId),
|
||||||
|
record.vehicleId ?? null,
|
||||||
|
].filter((id): id is string => Boolean(id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the full set of vehicles serving a last-mile delivery (multi-truck).
|
||||||
|
* Diffs against the current junction rows, syncing availability + audit history
|
||||||
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
|
*/
|
||||||
|
async setVehicles(
|
||||||
|
id: string,
|
||||||
|
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||||
|
): Promise<LastMile> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||||
|
const desiredMap = new Map<string, string | null>();
|
||||||
|
for (const inp of inputs) {
|
||||||
|
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||||
|
}
|
||||||
|
const desired = [...desiredMap.keys()];
|
||||||
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
const manager = this.dataSource.manager;
|
||||||
|
const current = await manager.find(LastMileVehicleAssignment, {
|
||||||
|
where: { lastMileId: id },
|
||||||
|
});
|
||||||
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
|
// old single-vehicle path has no junction row but must still be freed.
|
||||||
|
const releaseIds = [...new Set(
|
||||||
|
current.map((a) => a.vehicleId).concat(existing.vehicleId ? [existing.vehicleId] : []),
|
||||||
|
)];
|
||||||
|
const added = desired.filter((v) => !junctionSet.has(v));
|
||||||
|
const removed = releaseIds.filter((v) => !desiredSet.has(v));
|
||||||
|
// Vehicles that stay but whose container number changed.
|
||||||
|
const changed = current.filter(
|
||||||
|
(a) =>
|
||||||
|
desiredMap.has(a.vehicleId) &&
|
||||||
|
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (tx) => {
|
||||||
|
if (removed.length) {
|
||||||
|
await tx.delete(LastMileVehicleAssignment, {
|
||||||
|
lastMileId: id,
|
||||||
|
vehicleId: In(removed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await tx.insert(LastMileVehicleAssignment, {
|
||||||
|
lastMileId: id,
|
||||||
|
vehicleId,
|
||||||
|
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const row of changed) {
|
||||||
|
await tx.update(
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||||
|
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy primary vehicle = first of the set (null when cleared).
|
||||||
|
await this.lastMileRepository.update(id, { vehicleId: desired[0] ?? null } as any);
|
||||||
|
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
for (const vehicleId of added) {
|
||||||
|
await this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY);
|
||||||
|
void this.notifyDriverAssignment(vehicleId, existing);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_ASSIGNED,
|
||||||
|
vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
label: existing.status,
|
||||||
|
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const vehicleId of removed) {
|
||||||
|
await this.vehiclesService.releaseIfUnused([vehicleId]);
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record each truck's actual distance. The delivery total (exact_km) is their
|
||||||
|
* sum and drives billing; `remainingPayment` (total km × rate) is recomputed
|
||||||
|
* client-side. Does NOT generate an invoice — that's a separate explicit step.
|
||||||
|
*/
|
||||||
|
async setDistances(
|
||||||
|
id: string,
|
||||||
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
remainingPayment?: number,
|
||||||
|
): Promise<LastMile> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
// Distances are locked once the invoice exists.
|
||||||
|
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||||
|
if (invoices.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Distances cannot be changed after the invoice is generated',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of distances) {
|
||||||
|
await this.dataSource.manager.update(
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
{ lastMileId: id, vehicleId: d.vehicleId },
|
||||||
|
{ distanceKm: d.distanceKm },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = distances.reduce((s, d) => s + (Number(d.distanceKm) || 0), 0);
|
||||||
|
await this.lastMileRepository.update(id, {
|
||||||
|
exactKm: total,
|
||||||
|
...(remainingPayment != null ? { remainingPayment } : {}),
|
||||||
|
} as any);
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
private async notifyDriverAssignment(vehicleId: string, record: LastMile): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const vehicle = await this.vehiclesService.findById(vehicleId);
|
const vehicle = await this.vehiclesService.findById(vehicleId);
|
||||||
@@ -223,38 +558,54 @@ export class LastMileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
await this.lastMileRepository.softDelete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async allocateContainers(
|
// Can't delete once billed.
|
||||||
lastMileId: string,
|
const invoices = await this.billing.findBySourceIds('last_mile', [id]);
|
||||||
allocations: Array<{ containerId: string; vehicleId: string }>,
|
if (invoices.length) {
|
||||||
) {
|
throw new BadRequestException(
|
||||||
const lastMile = await this.findById(lastMileId);
|
'Cannot delete a last-mile delivery after its invoice is generated',
|
||||||
if (!lastMile) {
|
);
|
||||||
throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
// Every vehicle this delivery holds — junction + legacy + container rows.
|
||||||
for (const allocation of allocations) {
|
const assignments = await this.dataSource.manager.find(LastMileVehicleAssignment, {
|
||||||
await manager.delete(LastMileContainerAllocation, {
|
where: { lastMileId: id },
|
||||||
lastMileId,
|
});
|
||||||
containerId: allocation.containerId,
|
const allocations = await this.dataSource.manager.find(LastMileContainerAllocation, {
|
||||||
});
|
where: { lastMileId: id },
|
||||||
await manager.insert(LastMileContainerAllocation, {
|
});
|
||||||
lastMileId,
|
const vehicleIds = [
|
||||||
containerId: allocation.containerId,
|
...new Set(
|
||||||
vehicleId: allocation.vehicleId,
|
[
|
||||||
containerType: 'CONTAINER',
|
...assignments.map((a) => a.vehicleId),
|
||||||
quantity: 1,
|
...allocations.map((a) => a.vehicleId),
|
||||||
|
existing.vehicleId ?? null,
|
||||||
|
].filter((v): v is string => Boolean(v)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await this.lastMileRepository.softDelete(id);
|
||||||
|
if (assignments.length) {
|
||||||
|
await this.dataSource.manager.softDelete(LastMileVehicleAssignment, { lastMileId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free every vehicle no longer held by another active trip (releaseIfUnused
|
||||||
|
// ignores this now soft-deleted record) and audit the release.
|
||||||
|
if (vehicleIds.length) {
|
||||||
|
await this.vehiclesService.releaseIfUnused(vehicleIds);
|
||||||
|
const bookingRef = await this.resolveBookingRef(existing);
|
||||||
|
for (const vehicleId of vehicleIds) {
|
||||||
|
const info = await this.vehicleInfo(vehicleId);
|
||||||
|
await this.history.record({
|
||||||
|
eventType: FleetEventType.MILE_VEHICLE_RELEASED,
|
||||||
|
vehicleId,
|
||||||
|
lastMileId: id,
|
||||||
|
driverId: info.driverId,
|
||||||
|
metadata: { mile: 'LAST', bookingRef, vehiclePlate: info.plate, driverName: info.driverName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
allocated: allocations.length,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import { EmailClientService } from "./email-client.service";
|
|||||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
|
|
||||||
|
// Fall back to a sane broker URL so an unset RABBITMQ_URL can't produce
|
||||||
|
// `urls: [undefined]` (which crashes amqp-connection-manager on 'heartbeat').
|
||||||
|
const RABBITMQ_URL =
|
||||||
|
process.env.RABBITMQ_URL ?? process.env.PAYMENT_RABBITMQ_URL ?? "amqp://localhost:5672";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
@@ -16,7 +21,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
|
|||||||
name: "SMS_SERVICE",
|
name: "SMS_SERVICE",
|
||||||
transport: Transport.RMQ,
|
transport: Transport.RMQ,
|
||||||
options: {
|
options: {
|
||||||
urls: [process.env.RABBITMQ_URL as string],
|
urls: [RABBITMQ_URL],
|
||||||
queue: process.env.SMS_QUEUE ?? "sms_queue",
|
queue: process.env.SMS_QUEUE ?? "sms_queue",
|
||||||
queueOptions: { durable: true },
|
queueOptions: { durable: true },
|
||||||
},
|
},
|
||||||
@@ -25,7 +30,7 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
|
|||||||
name: "EMAIL_SERVICE",
|
name: "EMAIL_SERVICE",
|
||||||
transport: Transport.RMQ,
|
transport: Transport.RMQ,
|
||||||
options: {
|
options: {
|
||||||
urls: [process.env.RABBITMQ_URL as string],
|
urls: [RABBITMQ_URL],
|
||||||
queue: process.env.EMAIL_QUEUE ?? "email_queue",
|
queue: process.env.EMAIL_QUEUE ?? "email_queue",
|
||||||
queueOptions: { durable: true },
|
queueOptions: { durable: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ export class PaymentService {
|
|||||||
alreadyFinalized?: boolean;
|
alreadyFinalized?: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}> {
|
}> {
|
||||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||||
if (event.eventType === "payment.succeeded") {
|
if (event.eventType === "payment.succeeded") {
|
||||||
const intent = await this.paymentRepo.findOneBy({
|
const intent = await this.paymentRepo.findOneBy({
|
||||||
refId: event.referenceId,
|
refId: event.referenceId,
|
||||||
@@ -490,13 +490,12 @@ export class PaymentService {
|
|||||||
reason: `No local intent for reference ${event.referenceId}`,
|
reason: `No local intent for reference ${event.referenceId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
console.log(`Processing payment succeeded event for intent: }`, intent);
|
|
||||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||||
providerTxnId: event.providerTxnId,
|
providerTxnId: event.providerTxnId,
|
||||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
console.log(
|
this.logger.log(
|
||||||
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
parentGroupId?: string;
|
parentGroupId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isOpenTop?: boolean;
|
isOpenTop?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: true })
|
@ApiPropertyOptional({ default: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||||
import {
|
import {
|
||||||
RATE_APPLIES_TO,
|
RATE_APPLIES_TO,
|
||||||
RATE_TRIGGERS,
|
RATE_TRIGGERS,
|
||||||
@@ -51,15 +51,6 @@ export class CreateRateDto {
|
|||||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit!: string;
|
rateUnit!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
|
|
||||||
@IsDateString()
|
|
||||||
effectiveFrom!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsDateString()
|
|
||||||
effectiveTo?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubmitRateForApprovalDto {
|
export class SubmitRateForApprovalDto {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
|
||||||
|
|
||||||
@@ -22,12 +22,14 @@ export class CreateWeightLimitRuleDto {
|
|||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
maxVgmTons!: number;
|
maxVgmTons!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
|
@ApiPropertyOptional({
|
||||||
@IsDateString()
|
description:
|
||||||
effectiveFrom!: string;
|
'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.',
|
||||||
|
minimum: 0,
|
||||||
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsNumber()
|
||||||
effectiveTo?: string;
|
@Min(0)
|
||||||
|
@Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value)))
|
||||||
|
maxCapacityTons?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { CargoUnitOfMeasure } from '@edr/types';
|
import { CargoUnitOfMeasure } from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
@Index(['displayOrder'])
|
@Index(['displayOrder'])
|
||||||
@Index(['parentGroupId'])
|
@Index(['parentGroupId'])
|
||||||
|
@Index(['wagonTypeId'])
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
export class CargoType extends BaseEntity {
|
export class CargoType extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
|
||||||
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
|
|||||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
||||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
|
||||||
|
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
|
||||||
|
* through this FK. Nullable — grouping rows and container/legacy cargo never
|
||||||
|
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'wagon_type_id' })
|
||||||
|
wagonType?: WagonType | null;
|
||||||
|
|
||||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||||
requiresDirectorApproval!: boolean;
|
requiresDirectorApproval!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
import { WeightLimitRule } from './weight-limit-rule.entity';
|
import { WeightLimitRule } from './weight-limit-rule.entity';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'container_types' })
|
@Entity({ schema: 'freight', name: 'container_types' })
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
@Index(['isActive'])
|
@Index(['isActive'])
|
||||||
|
@Index(['wagonTypeId'])
|
||||||
export class ContainerType extends BaseEntity {
|
export class ContainerType extends BaseEntity {
|
||||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||||
code!: string;
|
code!: string;
|
||||||
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
|
|||||||
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
|
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
|
||||||
isOpenTop!: boolean;
|
isOpenTop!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wagon type that carries this container. Replaces the former hardcoded
|
||||||
|
* container wagon-code default (NW5): train scheduling resolves the container
|
||||||
|
* wagon type through this FK. Nullable; scheduling throws if a scheduled
|
||||||
|
* container type leaves it unset.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
|
||||||
|
wagonTypeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'wagon_type_id' })
|
||||||
|
wagonType?: WagonType | null;
|
||||||
|
|
||||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
isActive!: boolean;
|
isActive!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which rate units make sense for a given rate shape. The weighting basis is
|
||||||
|
* driven by the *type* of thing being billed — a container leg bills per
|
||||||
|
* container, bulk freight per ton, an intercity move can be per-km, a
|
||||||
|
* cancellation is a flat/per-invoice fee, and overweight is always per excess
|
||||||
|
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||||
|
* only pick a unit the pricing engine knows how to apply.
|
||||||
|
*
|
||||||
|
* Returned lists are ordered with the most natural/default unit first.
|
||||||
|
*/
|
||||||
|
export function allowedRateUnits(input: {
|
||||||
|
appliesTo: RateAppliesTo;
|
||||||
|
trigger: RateTrigger;
|
||||||
|
}): RateUnit[] {
|
||||||
|
const { appliesTo, trigger } = input;
|
||||||
|
|
||||||
|
// Surcharges (Applies to = Other) are governed by their trigger.
|
||||||
|
if (appliesTo === 'OTHER') {
|
||||||
|
switch (trigger) {
|
||||||
|
case 'OVERWEIGHT':
|
||||||
|
// Overweight always bills the excess tonnage — per ton, nothing else.
|
||||||
|
return ['PER_TON'];
|
||||||
|
case 'REEFER':
|
||||||
|
case 'HAZARDOUS':
|
||||||
|
// Scale with the freight shape: per container for boxes, per ton for bulk.
|
||||||
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
|
case 'DEMURRAGE':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
|
case 'CANCELLATION':
|
||||||
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
|
case 'CONSOLIDATION':
|
||||||
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
|
case 'SHIPPING_LINE':
|
||||||
|
case 'PIL_EXTRA_FEE':
|
||||||
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
|
default:
|
||||||
|
return ['FLAT', 'PER_TON', 'PER_CONTAINER'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base freight + first/last mile scale with the cargo type.
|
||||||
|
switch (appliesTo) {
|
||||||
|
case 'CONTAINER':
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
|
case 'BULK':
|
||||||
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
|
case 'INTERCITY':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||||||
|
case 'FIRST_MILE':
|
||||||
|
case 'LAST_MILE':
|
||||||
|
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
||||||
|
default:
|
||||||
|
return ['FLAT'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default (first / most natural) unit for a rate shape. */
|
||||||
|
export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit {
|
||||||
|
return allowedRateUnits(input)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `unit` is a valid weighting basis for the given rate shape. */
|
||||||
|
export function isRateUnitAllowed(input: {
|
||||||
|
appliesTo: RateAppliesTo;
|
||||||
|
trigger: RateTrigger;
|
||||||
|
unit: RateUnit;
|
||||||
|
}): boolean {
|
||||||
|
return allowedRateUnits(input).includes(input.unit);
|
||||||
|
}
|
||||||
@@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
@Entity({ schema: 'freight', name: 'rates' })
|
@Entity({ schema: 'freight', name: 'rates' })
|
||||||
@Index(['rateType'])
|
@Index(['rateType'])
|
||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@Index(['effectiveFrom'])
|
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['trigger'])
|
@Index(['trigger'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@@ -131,10 +130,4 @@ export class Rate extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||||||
approvedAt?: Date | null;
|
approvedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'effective_from', type: 'date' })
|
|
||||||
effectiveFrom!: Date;
|
|
||||||
|
|
||||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
|
||||||
effectiveTo?: Date | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity';
|
|||||||
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
|
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['tradeDirection'])
|
@Index(['tradeDirection'])
|
||||||
@Index(['effectiveFrom'])
|
|
||||||
export class WeightLimitRule extends BaseEntity {
|
export class WeightLimitRule extends BaseEntity {
|
||||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||||
containerTypeId!: string;
|
containerTypeId!: string;
|
||||||
@@ -20,9 +19,11 @@ export class WeightLimitRule extends BaseEntity {
|
|||||||
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
||||||
maxVgmTons!: number;
|
maxVgmTons!: number;
|
||||||
|
|
||||||
@Column({ name: 'effective_from', type: 'date', nullable: true })
|
/**
|
||||||
effectiveFrom!: Date;
|
* Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or
|
||||||
|
* below this is "overweight" (surcharge + warning); weight above this hard-
|
||||||
@Column({ name: 'effective_to', type: 'date', nullable: true })
|
* blocks booking creation entirely. Null = no ceiling (overweight only).
|
||||||
effectiveTo?: Date | null;
|
*/
|
||||||
|
@Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
|
||||||
|
maxCapacityTons!: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user