Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha

This commit is contained in:
Stephanos A
2026-06-29 21:13:32 +03:00
343 changed files with 38602 additions and 6691 deletions

View File

@@ -53,7 +53,7 @@ jobs:
NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$"
GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$"
DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true)
if [ -z "$DEPLOYABLE" ]; then
echo "Only non-deployable files changed. Skipping deploy."
@@ -142,6 +142,31 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./scripts/deploy/create-npmrc.sh
- name: Resolve env file path for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
run: |
case "${{ matrix.service }}" in
passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;;
payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;;
esac
- name: Build migration image for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker build \
--secret id=npmrc,src=.npmrc \
--target migration \
-f "apps/edr-${{ matrix.service }}/Dockerfile" \
-t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \
.
- name: Run migrations for ${{ matrix.service }}
if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service)
run: |
set -euo pipefail
docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration"
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail

View File

@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
# Freight org + staff (bookings / rule-engine IAM)
SEED_EDR_ORG=true
SEED_FREIGHT_STAFF=true
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
# MinIO (used by @tria-plc/iamapi-common for file storage)
MINIO_ENDPOINT=localhost

View File

@@ -19,8 +19,17 @@
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-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:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js"
},
"dependencies": {
"@edr/api-common": "workspace:*",
@@ -39,14 +48,15 @@
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
"dotenv-cli": "^11.0.0",
"handlebars": "^4.7.9",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",

View File

@@ -13,7 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
import { ContractsModule } from "./modules/contracts/contracts.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -52,8 +52,11 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module';
@@ -67,6 +70,7 @@ import { DriversModule } from './modules/drivers/drivers.module';
import { FirstMileModule } from './modules/first-mile/first-mile.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';
@Module({
imports: [
@@ -95,7 +99,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
BookingOrdersModule,
ContractsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
@@ -130,6 +134,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
FirstMileModule,
LastMileModule,
InterchangeDocumentsModule,
ImportOperationsModule,
],
providers: [
EdrOrgSeeder,
@@ -145,6 +150,9 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
Batch7TestDataSeeder,
Batch8TestDataSeeder,
WarehouseDemoSeeder,
ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder,
],
})
export class AppModule implements OnApplicationBootstrap {
@@ -161,6 +169,8 @@ export class AppModule implements OnApplicationBootstrap {
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
) { }
@@ -179,6 +189,8 @@ export class AppModule implements OnApplicationBootstrap {
await this.batch7TestDataSeeder.run();
await this.batch8TestDataSeeder.run();
await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
// Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

@@ -0,0 +1,262 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContractsRepository } from '../modules/contracts/contracts.repository';
import { Contract } from '../modules/contracts/entities/contract.entity';
import { ContractRoute } from '../modules/contracts/entities/contract-route.entity';
import {
ContractSignature,
ContractSignerRole,
} from '../modules/contracts/entities/contract-signature.entity';
import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service';
import { ContractTemplateResolver } from './contract-template.resolver';
import { getTemplateMeta } from './contract-template.registry';
import { ContractViewModel } from './contract-view-model.builder';
/**
* Signature row for the contract PDF. Mirrors the booking builder's
* `ContractSignatureView` but widens `role` to the contract's signer roles
* (CUSTOMER | STAFF | DIRECTOR | CEO).
*/
export interface ContractDocumentSignatureView {
role: ContractSignerRole;
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
}
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
export interface ContractUnitRateRow {
label: string;
unitPrice: number;
unit: string;
currency: string;
}
/**
* Pricing schedule for a Contract document: a unit-rate schedule (one price per
* unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped
* to stay structurally compatible with the renderer's expectations of
* {@link ContractViewModel.pricing} (it reads `currency`).
*/
export interface ContractUnitRateSchedule {
displayMode: 'UNIT_RATES';
unitRates: ContractUnitRateRow[];
currency: string;
equipmentReturn?: string;
originLabel: string;
destinationLabel: string;
}
/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */
function unitLabel(unit: string): string {
switch (unit) {
case 'per_container':
return 'container';
case 'per_ton':
return 'ton';
case 'per_item':
return 'item';
case 'per_km':
return 'km';
default:
return 'unit';
}
}
/**
* Builds the contract PDF view-model from the {@link Contract} aggregate (the new
* source of truth) — mirrors {@link ContractViewModelBuilder} but every field is
* sourced from the contract, its routes, cargo scope and unit-rate breakdown.
* The legacy booking-based builder remains untouched for the migration window.
*/
@Injectable()
export class ContractDocumentViewModelBuilder {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly templateResolver: ContractTemplateResolver,
) {}
async build(
contractId: string,
): Promise<{ contract: Contract; view: ContractViewModel }> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) {
throw new NotFoundException(`Contract ${contractId} not found`);
}
const templateKey =
contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract));
const template = getTemplateMeta(templateKey);
const pricing = this.buildPricing(contract);
const signatures = await this.loadSignatures(contractId);
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
const hasStaff = signatures.some((s) => s.role === 'STAFF');
const hasContractFile = Boolean(
contract.files?.some((f) => f.code === 'contract'),
);
const view: ContractViewModel = {
bookingId: contract.id,
reference: contract.reference,
status: contract.status,
templateKey,
template,
contractDate: new Date().toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
}),
contractYear: new Date().getFullYear(),
client: {
companyName: contract.company?.name ?? 'Client',
companyAddress: this.valueOrDash(contract.company?.address),
companyLocation: this.valueOrDash(contract.company?.country),
phone: this.valueOrDash(contract.company?.phone),
email: this.valueOrDash(contract.company?.email),
tinNumber: this.valueOrDash(contract.company?.tin),
vatNumber: this.valueOrDash(contract.company?.vatNumber),
fanNumber: this.valueOrDash(contract.company?.fanNumber),
businessLicense: this.valueOrDash(
contract.company?.companyProfiles?.[0]?.businessLicense,
),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
address: 'Addis Ababa, Ethiopia',
phone: '+251 11 872 0000',
email: 'info@edr.gov.et',
tinNumber: '—',
},
schedule: this.buildSchedule(contract),
pricing: pricing as unknown as ContractViewModel['pricing'],
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
// view-model's narrower CUSTOMER|STAFF role union.
signatures: signatures as unknown as ContractViewModel['signatures'],
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
canSignStaff:
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
};
return { contract, view };
}
private async loadSignatures(
contractId: string,
): Promise<ContractDocumentSignatureView[]> {
const rows = await this.contractsRepository.findSignatures(contractId);
return rows.map((s) => this.toSignatureView(s));
}
toSignatureView(row: ContractSignature): ContractDocumentSignatureView {
return {
role: row.role,
signerDisplayName: row.signerDisplayName,
signedAt: this.formatDate(row.signedAt),
signatureImageUrl: row.signatureFile?.url ?? null,
};
}
/** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */
private buildPricing(contract: Contract): ContractUnitRateSchedule {
const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null;
const currency = breakdown?.currency ?? contract.paymentCurrency;
const lineItems = breakdown?.lineItems ?? [];
const firstRoute = this.firstRoute(contract);
return {
displayMode: 'UNIT_RATES',
unitRates: lineItems.map((line) => ({
label: line.label,
unitPrice: line.unitPrice,
unit: unitLabel(line.unit),
currency,
})),
currency,
equipmentReturn: contract.equipmentReturn ?? '—',
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
};
}
private buildSchedule(contract: Contract): ContractViewModel['schedule'] {
const firstRoute = this.firstRoute(contract);
const cargoScope = (contract.cargoScope ?? [])[0];
const cargoName =
cargoScope?.cargoType?.cargoTypeName ||
cargoScope?.cargoFreeText ||
(cargoScope?.containerSize
? `${cargoScope.containerSize} container`
: 'Container cargo');
return {
originLabel: this.yardLabel(firstRoute?.originYard),
destinationLabel: this.yardLabel(firstRoute?.destinationYard),
tradeDirection: this.valueOrDash(contract.tradeDirection),
freightType: this.valueOrDash(contract.freightType),
serviceType: this.valueOrDash(
contract.serviceType?.serviceName ?? contract.serviceType?.code,
),
scheduledDate: this.formatDate(contract.estimatedShipmentDate),
contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—',
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
};
}
/** The contract's primary route (lowest sortOrder), used for origin/destination labels. */
private firstRoute(contract: Contract): ContractRoute | undefined {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
);
return routes[0];
}
/**
* The template resolver reads a Booking; a contract carries equivalent fields
* under a different shape (cargoType lives on cargoScope). Build a minimal,
* structurally-compatible adapter rather than widening the resolver signature.
*/
private toResolverInput(
contract: Contract,
): Parameters<ContractTemplateResolver['resolve']>[0] {
const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType;
return {
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
paymentCurrency: contract.paymentCurrency,
cargoType: cargoType ?? undefined,
serviceType: contract.serviceType,
} as Parameters<ContractTemplateResolver['resolve']>[0];
}
private yardLabel(yard?: { label?: string; code?: string } | null): string {
return this.valueOrDash(yard?.label ?? yard?.code);
}
private formatDate(value?: Date | string | null): string {
if (!value) return '—';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
private valueOrDash(value?: string | number | null): string {
if (value === undefined || value === null || value === '') return '—';
return String(value);
}
}
// Re-export for callers that want the role union without importing the entity.
export type { ContractSignerRole };

View File

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

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPostPaymentCompletedColumn1719667261000 implements MigrationInterface {
name = 'AddPostPaymentCompletedColumn1719667261000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries ADD COLUMN IF NOT EXISTS is_post_payment_completed BOOLEAN NOT NULL DEFAULT false;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.last_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
await queryRunner.query(`ALTER TABLE freight.first_mile_deliveries DROP COLUMN IF EXISTS is_post_payment_completed;`);
}
}

View File

@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seeds the admin-configurable "contract validity periods" setting (days). Stored
* as a dropdown_settings row whose options each hold a day count in `value`, so
* backoffice manages them through the existing Dropdown Settings UI and the
* contract staff-accept dialog only offers the configured durations.
*/
export class SeedContractValidityPeriods1792000000004
implements MigrationInterface
{
name = 'SeedContractValidityPeriods1792000000004';
private readonly code = 'contract_validity_periods';
private readonly options: Array<{ value: string; label: string }> = [
{ value: '180', label: '6 months' },
{ value: '365', label: '1 year' },
{ value: '730', label: '2 years' },
];
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
VALUES ($1, $2, $3, false)
RETURNING id;`,
[
this.code,
'Contract Validity Periods (days)',
'Validity durations (in days) a staff can choose when accepting a submitted contract.',
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const opt = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, opt.value, opt.label, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
[this.code],
);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC,
ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.vehicles
DROP COLUMN IF EXISTS estimated_distance_km,
DROP COLUMN IF EXISTS actual_distance_km;
`);
}
}

View File

@@ -0,0 +1,357 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* ContractBooking separation (additive phase). Introduces a first-class
* `freight.contracts` aggregate that owns the legal/commercial agreement (scope
* + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`.
*
* Purely additive: no legacy columns are dropped here. The data backfill and
* legacy-column removal happen in a later cutover migration.
*
* See docs/new-doc.md §5.
*/
export class CreateContracts1822000000000 implements MigrationInterface {
name = 'CreateContracts1822000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ── contracts ───────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contracts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reference VARCHAR(64) NOT NULL UNIQUE,
company_id UUID,
company_profile_id UUID,
is_government BOOLEAN NOT NULL DEFAULT FALSE,
government_institution VARCHAR(255),
contract_kind VARCHAR(20) NOT NULL,
renewal_of_id UUID REFERENCES freight.contracts(id),
trade_direction VARCHAR(10) NOT NULL,
freight_type VARCHAR(20) NOT NULL,
service_type_id UUID NOT NULL,
payment_currency VARCHAR(5) NOT NULL,
customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE,
customs_clearing_agent VARCHAR(200),
equipment_return VARCHAR(20),
first_mile_pickup_address TEXT,
first_mile_pickup_lat NUMERIC(10,7),
first_mile_pickup_lng NUMERIC(10,7),
last_mile_delivery_address TEXT,
last_mile_delivery_lat NUMERIC(10,7),
last_mile_delivery_lng NUMERIC(10,7),
is_hazardous BOOLEAN NOT NULL DEFAULT FALSE,
is_reefer BOOLEAN NOT NULL DEFAULT FALSE,
estimated_shipment_date TIMESTAMPTZ,
contract_validity_days INT,
contract_valid_from TIMESTAMPTZ,
contract_valid_until TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
status VARCHAR(40) NOT NULL DEFAULT 'DRAFT',
clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE',
clearance_cycle_number INT NOT NULL DEFAULT 0,
pricing_breakdown JSONB,
pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES',
contract_type VARCHAR(20),
contract_template_key VARCHAR(128),
contract_generated_at TIMESTAMPTZ,
contract_summary TEXT,
version_number INT NOT NULL DEFAULT 1,
financial_terms JSONB,
approved_by_staff_id UUID,
approved_by_staff_at TIMESTAMPTZ,
signed_by_director_id UUID,
signed_by_director_at TIMESTAMPTZ,
signed_by_ceo_id UUID,
signed_by_ceo_at TIMESTAMPTZ,
customer_signed_at TIMESTAMPTZ,
fully_executed_at TIMESTAMPTZ,
locked_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_contracts_company ON freight.contracts(company_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`);
// ── contract_routes ──────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
origin_yard_id UUID NOT NULL,
destination_yard_id UUID NOT NULL,
km NUMERIC(10,2),
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id)
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`);
// ── contract_cargo_scope ─────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
container_size VARCHAR(10),
cargo_type_id UUID,
cargo_free_text VARCHAR(200),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size)
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`);
// ── contract_signatures ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_signatures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
signer_display_name VARCHAR(255) NOT NULL,
signature_file_id UUID,
consent_text TEXT,
signed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
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_contract_signatures_contract ON freight.contract_signatures(contract_id);`);
// ── contract_approval_steps ──────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_approval_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
step_order SMALLINT NOT NULL DEFAULT 0,
required_role VARCHAR(40) NOT NULL,
blocks_role VARCHAR(40),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
acted_by_staff_id UUID,
acted_at TIMESTAMPTZ,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`);
// ── contract_rate_snapshots ──────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
rate_id UUID,
rate_code VARCHAR(64) NOT NULL,
description VARCHAR(255),
unit_price NUMERIC(14,2) NOT NULL,
unit_of_measure VARCHAR(32) NOT NULL,
currency VARCHAR(5) NOT NULL,
container_size VARCHAR(10),
is_surcharge BOOLEAN DEFAULT FALSE,
conditional_on VARCHAR(32),
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_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`);
// ── contract_review_notes ────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_review_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
note_type VARCHAR(40) NOT NULL,
body TEXT NOT NULL,
author_role VARCHAR(20),
author_user_id UUID,
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_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`);
// ── contract_clearance_cycles ────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
cycle_number INT NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS',
booking_id UUID,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
clearance_ready_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number)
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`);
// ── contract_document_review (pre-booking clearance, Path B) ─────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.contract_document_review (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE,
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
setting_code VARCHAR(128) NOT NULL,
file_key VARCHAR(128) NOT NULL,
file_record_id UUID,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
note TEXT,
uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER',
reviewed_by_staff_id UUID,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_contract_document_review_doc
UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key)
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`);
// ── clearance_milestones (GL tracking) ───────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.clearance_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE,
contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE,
clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id),
milestone_code VARCHAR(64) NOT NULL,
milestone_label VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
owner_region VARCHAR(5),
triggered_by_doc BOOLEAN DEFAULT FALSE,
triggered_at TIMESTAMPTZ,
triggered_by_user_id UUID,
note TEXT,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`);
// booking-scoped and contract-cycle-scoped uniqueness for milestone codes
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking
ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL;
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle
ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL;
`);
// ── booking_container_units (per-unit container detail) ──────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_container_units (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE,
container_number VARCHAR(64) NOT NULL,
seal_number VARCHAR(64),
vgm_tons NUMERIC(10,3) NOT NULL,
is_hazardous BOOLEAN DEFAULT FALSE,
is_reefer BOOLEAN DEFAULT FALSE,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number)
);
`);
// ── ALTER bookings ───────────────────────────────────────────────────────
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`);
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`);
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`);
// One active booking per ONE_TIME contract. Postgres forbids a subquery in an
// index predicate, so we denormalize the contract kind onto the booking and
// predicate on that. The column is stamped at booking creation from the
// contract; the app layer (ContractBookingService) is the primary guard and
// this index is the backstop.
await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract
ON freight.bookings (contract_id)
WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED')
AND contract_id IS NOT NULL
AND contract_kind = 'ONE_TIME';
`);
// ── ALTER booking_container ──────────────────────────────────────────────
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`);
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`);
await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`);
// ── ALTER booking_document_review (denormalized contract link) ───────────
await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`);
// ── Extend file_upload_fields with phased GL metadata ────────────────────
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`);
await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`);
await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`);
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`);
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`);
await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`);
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
name = 'CreateImportDjiboutiOperations1822000000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'import_djibouti_operations',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex(
'freight.import_djibouti_operations',
new TableIndex({
name: 'idx_import_djibouti_operations_schedule',
columnNames: ['train_schedule_id'],
}),
);
await queryRunner.createForeignKey(
'freight.import_djibouti_operations',
new TableForeignKey({
columnNames: ['train_schedule_id'],
referencedTableName: 'train_schedules',
referencedSchema: 'freight',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.import_djibouti_operations', true);
}
}

View File

@@ -0,0 +1,175 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Data backfill for the contractbooking separation (docs/new-doc.md §17).
*
* For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a
* `freight.contracts` row from its contract-phase columns, copy its routes
* (contract_route_lines → contract_routes, dropping quantity), and point the
* contract + every child shipment booking (linked via booking_orders) at it.
*
* Per §19 item 1, historical ONE_TIME bookings that went through the full
* contract flow get a contract parent inserted and `contract_id` set on the same
* booking row (no row split).
*
* Idempotent: skips bookings that already have `contract_id` set, and matches a
* synthesised contract by a deterministic `CTR-<bookingId>` reference.
*/
export class BackfillContractsFromBookings1823000000000
implements MigrationInterface
{
name = 'BackfillContractsFromBookings1823000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase
// columns. Reference is derived from the source booking id so re-runs are
// idempotent (ON CONFLICT DO NOTHING on the unique reference).
await queryRunner.query(`
INSERT INTO freight.contracts (
reference, company_id, company_profile_id, is_government, government_institution,
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
customs_clearing_enabled, customs_clearing_agent, equipment_return,
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
is_hazardous, is_reefer, estimated_shipment_date,
contract_validity_days, contract_valid_from, contract_valid_until, expires_at,
status, clearance_status, clearance_cycle_number,
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
contract_summary, version_number,
approved_by_staff_id, approved_by_staff_at,
signed_by_director_id, signed_by_director_at,
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
created_at, updated_at
)
SELECT
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at,
CASE
WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE'
WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED'
WHEN b.status IN ('EXPIRED') THEN 'EXPIRED'
WHEN b.status IN ('CANCELLED') THEN 'CANCELLED'
WHEN b.status IN ('REJECTED') THEN 'REJECTED'
ELSE 'CONTRACT_ACTIVE'
END,
CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END,
0,
b.pricing_breakdown,
b.contract_type, b.contract_template_key, b.contract_generated_at,
b.contract_summary, COALESCE(b.version_number, 1),
b.approved_by_staff_id, b.approved_by_staff_at,
b.signed_by_director_id, b.signed_by_director_at,
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
b.created_at, b.updated_at
FROM freight.bookings b
WHERE b.booking_type = 'GENERAL_CONTRACT'
ON CONFLICT (reference) DO NOTHING;
`);
// 2. Copy each general contract's route lines into contract_routes (no qty).
await queryRunner.query(`
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at)
SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now()
FROM freight.contract_route_lines crl
JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
`);
// 3. Point the general-contract booking itself at its new contract, and stamp
// the denormalized contract_kind for the active-booking index.
await queryRunner.query(`
UPDATE freight.bookings b
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
FROM freight.contracts c
WHERE c.reference = 'CTR-' || b.id::text
AND b.booking_type = 'GENERAL_CONTRACT'
AND b.contract_id IS NULL;
`);
// 4. Point each child shipment booking (spawned via booking_orders) at the
// same contract as its parent general contract.
await queryRunner.query(`
UPDATE freight.bookings child
SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER'
FROM freight.booking_orders bo
JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text
WHERE child.id = bo.booking_id
AND child.contract_id IS NULL;
`);
// 5. Historical ONE_TIME bookings that completed the contract flow: synthesise
// a contract parent and point the same booking row at it (no row split).
await queryRunner.query(`
INSERT INTO freight.contracts (
reference, company_id, company_profile_id, is_government, government_institution,
contract_kind, trade_direction, freight_type, service_type_id, payment_currency,
customs_clearing_enabled, customs_clearing_agent, equipment_return,
first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng,
last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng,
is_hazardous, is_reefer, estimated_shipment_date,
contract_validity_days, contract_valid_from, contract_valid_until,
status, clearance_status, clearance_cycle_number,
pricing_breakdown, contract_type, contract_template_key, contract_generated_at,
contract_summary, version_number,
approved_by_staff_id, approved_by_staff_at,
signed_by_director_id, signed_by_director_at,
signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at,
created_at, updated_at
)
SELECT
'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution,
'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency,
b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return,
b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng,
b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng,
b.is_hazardous, b.is_reefer, b.estimated_shipment_date,
b.contract_validity_days, b.contract_valid_from, b.contract_valid_until,
'FULLY_EXECUTED', 'NOT_APPLICABLE', 0,
b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at,
b.contract_summary, COALESCE(b.version_number, 1),
b.approved_by_staff_id, b.approved_by_staff_at,
b.signed_by_director_id, b.signed_by_director_at,
b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at,
b.created_at, b.updated_at
FROM freight.bookings b
WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
AND b.contract_id IS NULL
AND b.contract_generated_at IS NOT NULL
ON CONFLICT (reference) DO NOTHING;
`);
await queryRunner.query(`
UPDATE freight.bookings b
SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER'
FROM freight.contracts c
WHERE c.reference = 'CTR-' || b.id::text
AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME'
AND b.contract_id IS NULL;
`);
// 6. Build a single route per ONE_TIME contract from the booking's own
// origin/destination (general contracts already got their routes in step 2).
await queryRunner.query(`
INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at)
SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now()
FROM freight.bookings b
JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME'
WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL
ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Unlink bookings and drop the synthesised contracts (and their cascaded routes).
await queryRunner.query(`
UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL
WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%');
`);
await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`);
}
}

View File

@@ -0,0 +1,95 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
name = 'CreateImportOperationsTables1823000000000';
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'djibouti_import_incidents',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid' },
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
{ name: 'cargo_id', type: 'uuid', isNullable: true },
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
{ name: 'incident_type', type: 'varchar', length: '40' },
{ name: 'description', type: 'text' },
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'reported_at', type: 'timestamptz' },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'import_customs_finalizations',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'booking_id', type: 'uuid', isUnique: true },
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'empty_container_returns',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
{ name: 'container_number', type: 'varchar', length: '80' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'customer_id', type: 'uuid', isNullable: true },
{ name: 'return_date', type: 'timestamptz' },
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
{ name: 'condition', type: 'text', isNullable: true },
{ name: 'handover_note', type: 'text', isNullable: true },
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.empty_container_returns', true);
await queryRunner.dropTable('freight.import_customs_finalizations', true);
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Cutover cleanup (docs/new-doc.md §17 Phase 4). Runs AFTER the backfill
* (1823…) so every legacy general contract + drawdown already lives in the
* `contracts` aggregate.
*
* Drops the now-unused booking-as-contract artifacts:
* - `bookings.booking_type` (every booking is a real shipment now)
* - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`)
* - the `booking_orders` / `booking_order_lines` drawdown ledger
* - `contract_route_lines` (superseded by `contract_routes`)
*
* The shipment/payment/scheduling/allocation columns on `bookings` are kept —
* the operational pipeline is unchanged.
*/
export class DropLegacyContractTables1824000000000 implements MigrationInterface {
name = 'DropLegacyContractTables1824000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// booking_order_lines references booking_orders → drop child first.
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`);
await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Re-add the dropped columns (data is not restored — this is a one-way cutover).
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`,
);
// The legacy ledger/route tables are intentionally NOT recreated here; restore
// from a backup if a rollback past the cutover is ever required.
}
}

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Global Logistics Phase-2 operational features (docs/new-doc.md §11§13, gap
* matrix #14/#16/#17/#18):
* - `clearance_milestones.metadata` — structured payload for RISK_ASSIGNED
* (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
* - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
* — station routing + staff binding (GL US-02)
* - `freight.clearance_incidents` — cargo exception/damage reports with photos
* (GL Import US-07)
*/
export class AddGlOperations1825000000000 implements MigrationInterface {
name = 'AddGlOperations1825000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
incident_type VARCHAR(32) NOT NULL,
description TEXT NOT NULL,
photo_file_ids JSONB NOT NULL DEFAULT '[]',
reported_by_user_id UUID,
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
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_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
);
await queryRunner.query(
`ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* GENERAL contracts can be booked repeatedly until a total cargo quantity cap is
* reached (e.g. 100 containers across many shipments). `quantity_cap` on each
* cargo-scope line holds that ceiling (containers per size, or tons/items for
* bulk). NULL = uncapped; always NULL for ONE_TIME (single booking).
*/
export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface {
name = 'AddCargoScopeQuantityCap1826000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`,
);
}
}

View File

@@ -0,0 +1,75 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
/**
* Customer shipment requests for GENERAL customs (Path B) contracts. The customer
* submits date + quantities; Global Logistics reviews, then creates the booking
* on their behalf and per-booking clearance begins. Additive — no change to
* existing tables; ONE_TIME contracts are unaffected.
*/
export class CreateBookingRequests1827000000000 implements MigrationInterface {
name = 'CreateBookingRequests1827000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_requests',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'reference', type: 'varchar', length: '40', default: "''" },
{ name: 'contract_id', type: 'uuid' },
{ name: 'requested_by_user_id', type: 'uuid', isNullable: true },
{ name: 'contract_route_id', type: 'uuid', isNullable: true },
{ name: 'scheduled_date', type: 'timestamptz', isNullable: true },
{ name: 'status', type: 'varchar', length: '16', default: "'PENDING'" },
{ name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" },
{ name: 'notes', type: 'text', isNullable: true },
{ name: 'created_booking_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
{ name: 'review_note', type: 'text', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['contract_id'],
referencedSchema: 'freight',
referencedTableName: 'contracts',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
{
columnNames: ['created_booking_id'],
referencedSchema: 'freight',
referencedTableName: 'bookings',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }),
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }),
);
await queryRunner.createIndex(
'freight.booking_requests',
new TableIndex({
name: 'idx_booking_requests_contract_status',
columnNames: ['contract_id', 'status'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_requests', true);
}
}

View File

@@ -1,62 +0,0 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingOrdersService } from './booking-orders.service';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { GeneralContractService } from './general-contract.service';
@ApiTags('Booking Orders')
@Controller('booking-orders')
export class BookingOrdersController {
constructor(
private readonly ordersService: BookingOrdersService,
private readonly generalContractService: GeneralContractService,
) {}
@Post()
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
async create(
@Body() dto: CreateBookingOrderDto,
@CurrentUser() user: TCurrentUser,
) {
return this.ordersService.create(dto, user?.id);
}
@Get()
@ApiOperation({ summary: 'List orders placed against a contract' })
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
return this.ordersService.listByContract(contractBookingId);
}
@Get('contract/:id/pool')
@ApiOperation({
summary: 'Contracted / ordered / remaining quantities for a general contract',
})
async pool(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getQuantityLines(id);
}
@Get('contract/:id/routes')
@ApiOperation({
summary:
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
})
async routes(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getRouteLines(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single booking order' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ordersService.findById(id);
}
}

View File

@@ -1,33 +0,0 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { CompaniesModule } from '../companies/companies.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { BookingOrdersController } from './booking-orders.controller';
import { BookingOrdersRepository } from './booking-orders.repository';
import { BookingOrdersService } from './booking-orders.service';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import { GeneralContractService } from './general-contract.service';
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
RuleEngineModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],
providers: [
BookingOrdersService,
BookingOrdersRepository,
GeneralContractService,
],
exports: [BookingOrdersService, GeneralContractService],
})
export class BookingOrdersModule {}

View File

@@ -1,41 +0,0 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingOrder } from './entities/booking-order.entity';
@Injectable()
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
constructor(
@InjectRepository(BookingOrder)
repository: Repository<BookingOrder>,
) {
super(repository);
}
/** Orders placed against a given contract, newest first, with their lines. */
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.repository.find({
where: { contractBookingId },
relations: { lines: { containerType: true }, booking: true },
order: { createdAt: 'DESC' },
});
}
override findById(id: string): Promise<BookingOrder | null> {
return this.repository.findOne({
where: { id },
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
});
}
/** Count this calendar year's orders, for reference generation. */
async countByYear(year: number): Promise<number> {
const start = new Date(Date.UTC(year, 0, 1));
const end = new Date(Date.UTC(year + 1, 0, 1));
return this.repository
.createQueryBuilder('o')
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
.getCount();
}
}

View File

@@ -1,127 +0,0 @@
import { BookingOrdersService } from './booking-orders.service';
/**
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
* waits for Marketing review (or the customs clearance gate first) — it does
* NOT auto-enter the train batch pool, and the contract is not charged.
*/
describe('BookingOrdersService — child spawn on order create', () => {
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
const contract = {
id: 'c-1',
bookingType: 'GENERAL_CONTRACT',
status: 'CONTRACT_ACTIVE',
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
freightType: 'BULK',
originYardId: 'o-1',
destinationYardId: 'd-1',
companyId: null,
paymentCurrency: 'ETB',
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
bookingContainers: [],
};
// Capture what status the child is created with.
const created: Record<string, unknown>[] = [];
const managerUpdates: Record<string, unknown>[] = [];
const fakeManager = {
create: (_entity: unknown, data: Record<string, unknown>) => {
created.push(data);
return { id: 'child-1', ...data };
},
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
getRepository: () => ({
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
update: async (_id: string, data: Record<string, unknown>) => {
managerUpdates.push(data);
},
}),
};
const dataSource = {
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
getRepository: () => ({ update: jest.fn() }),
};
const ordersRepository = {
countByYear: jest.fn().mockResolvedValue(0),
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
};
const bookingsRepository = {
findById: jest.fn().mockResolvedValue(contract),
countByYear: jest.fn().mockResolvedValue(0),
};
const generalContractService = {
isGeneralContract: () => true,
getRouteLines: jest.fn().mockResolvedValue([]),
getQuantityLines: jest
.fn()
.mockResolvedValue([
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
]),
isExhausted: jest.fn().mockResolvedValue(false),
};
const pricingService = {
computePriceForBooking: jest.fn().mockResolvedValue({
totalAmount: 500,
priorityScore: 10,
lineItems: [],
currency: 'ETB',
}),
};
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
const trainSchedulingService = {
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
};
const companiesService = {};
const service = new BookingOrdersService(
dataSource as never,
ordersRepository as never,
bookingsRepository as never,
companiesService as never,
generalContractService as never,
pricingService as never,
ratesService as never,
trainSchedulingService as never,
);
return { service, created, managerUpdates, pricingService };
}
const dto = {
contractBookingId: 'c-1',
scheduledDate: '2026-07-01T00:00:00.000Z',
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
};
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
const { service, created, managerUpdates, pricingService } = makeService({
includesCustoms: false,
});
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
expect(child.paymentStatus).toBe('PENDING');
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
// The computed price is persisted onto the child.
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
});
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
const { service, created } = makeService({ includesCustoms: true });
await service.create(dto as never);
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
expect(child.status).toBe('AWAITING_DOCUMENTS');
});
it('rejects when hazardous quantity exceeds the line quantity', async () => {
const { service } = makeService({ includesCustoms: false });
await expect(
service.create({
...dto,
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
} as never),
).rejects.toThrow(/exceed the line quantity/);
});
});

View File

@@ -1,469 +0,0 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { RatesService } from '../rule-engine/services/rates.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
import { isRoadService, roadKmPrice } from './road.util';
@Injectable()
export class BookingOrdersService {
private readonly logger = new Logger(BookingOrdersService.name);
constructor(
private readonly dataSource: DataSource,
private readonly ordersRepository: BookingOrdersRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
private readonly pricingService: BookingPricingService,
private readonly ratesService: RatesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
* Place a drawdown order against an ACTIVE general contract.
*
* Validates the requested quantities against the remaining pool, then spawns a
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
* route/cargo/service) so it flows through the existing train-scheduling
* pipeline. The order row is the ledger entry linking contract → child booking.
*/
async create(
dto: CreateBookingOrderDto,
userId?: string,
): Promise<BookingOrder> {
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
if (!contract) {
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
}
if (!this.generalContractService.isGeneralContract(contract)) {
throw new BadRequestException('Booking is not a general contract');
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
);
}
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Contract ordering window has expired');
}
// The customer placing the order must own the contract.
if (userId && !(await this.userOwnsContract(userId, contract))) {
throw new BadRequestException('You do not have access to this contract');
}
// Resolve the route the order ships on: a chosen contract route line for a
// multi-route contract, else the contract's own origin/destination.
const routeLines = await this.generalContractService.getRouteLines(
contract.id,
);
let originYardId = contract.originYardId;
let destinationYardId = contract.destinationYardId;
let routeLineId: string | null = null;
let routeKm: number | null = null;
if (routeLines.length > 0) {
if (!dto.routeLineId) {
throw new BadRequestException(
'This contract has multiple routes — select a route to draw from',
);
}
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
if (!chosen) {
throw new BadRequestException(
'Selected route is not part of this contract',
);
}
originYardId = chosen.originYardId;
destinationYardId = chosen.destinationYardId;
routeLineId = chosen.routeLineId;
routeKm = chosen.km ?? null;
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
const isContainer = contract.freightType === 'CONTAINER';
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
for (const line of dto.lines) {
const haz = line.hazardousQuantity ?? 0;
const reefer = line.reeferQuantity ?? 0;
if (haz < 0 || reefer < 0) {
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
}
if (haz > line.quantity || reefer > line.quantity) {
throw new BadRequestException(
'Hazardous/reefer quantity cannot exceed the line quantity',
);
}
}
// The contract has a single shared drawdown pool (per container type for
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
// only fixed origin/destination/km above — so every order, routed or not,
// validates each line against the same shared pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(
contract,
dto,
{ originYardId, destinationYardId, km: routeKm },
manager,
);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
routeLineId,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
// The order is a ledger row; the child booking drives the workflow
// (review → pay → allocate), so the order tracks PENDING until done.
status: 'PENDING',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
const lines = dto.lines.map((l) =>
manager.create(BookingOrderLine, {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity ?? 0,
reeferQuantity: l.reeferQuantity ?? 0,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// The child does NOT enter the train batch pool here. It is priced and
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
// clearance first; the batch enqueue happens only on accept.
// Close the contract once its pool is exhausted (pending orders count, so
// the pool reserves quantity as soon as an order is placed).
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
.update(contract.id, { status: 'CONTRACT_CLOSED' });
this.logger.log(
`Contract ${contract.reference} CLOSED — quantity exhausted`,
);
}
return (await this.ordersRepository.findById(order.id)) ?? order;
}
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context. Unlike the contract (which is no longer paid up front),
* the child is PRICED and UNPAID and waits for Marketing review — going
* through the customs clearance gate first when the service includes customs,
* mirroring a one-time booking. It only enters the train pool on accept.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
route: { originYardId: string; destinationYardId: string; km: number | null },
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
const containerByType = new Map(
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
);
let totalWeight = 0;
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
totalWeight += vgmPerUnit * line.quantity;
}
} else {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
// Per-order hazardous/reefer: set the child flags from the order's line
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
// Customs orders flow through the one-time clearance gate first; others go
// straight to operations review with the chosen shipment day.
const { includesCustoms } = clearanceCodesForBooking(contract);
const spawnStatus = includesCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,
previousContractId: contract.id,
serviceTypeId: contract.serviceTypeId,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: hasHazardous,
isReefer: hasReefer,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Priced + unpaid: the customer pays this order on its own.
status: spawnStatus,
paymentStatus: 'PENDING',
priorityScore: contract.priorityScore,
totalAmount: 0,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const ct = line.containerTypeId
? await manager.getRepository(ContainerType).findOne({
where: { id: line.containerTypeId },
})
: null;
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
const row = manager.create(BookingContainer, {
bookingId: savedChild.id,
containerTypeId: line.containerTypeId ?? null,
quantity: line.quantity,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
isOverweight: false,
});
await manager.save(row);
}
}
// Price the order: base freight for the drawn quantity + haz/reefer
// surcharges, plus a road KM charge when the service ships by road.
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
await this.priceChildBooking(savedChild.id, roadKm, manager);
return savedChild;
}
/**
* Compute and persist the child order's price (base + surcharges) inside the
* order transaction. The contract is no longer paid up front, so each order
* carries its own total that the customer pays.
*/
private async priceChildBooking(
childId: string,
roadKm: number | null,
manager: import('typeorm').EntityManager,
): Promise<void> {
const child = await manager.getRepository(Booking).findOne({
where: { id: childId },
relations: { bookingContainers: true },
});
if (!child) return;
try {
const computed = await this.pricingService.computePriceForBooking(child);
const lineItems = [...computed.lineItems];
let total = computed.totalAmount;
// Road KM charge: distance × the live PER_KM rate, added as its own line.
if (roadKm && roadKm > 0) {
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
const kmAmount = roadKmPrice(roadKm, perKmRate);
if (kmAmount > 0) {
lineItems.push({
code: 'ROAD_KM',
description: `Road transport (${roadKm} km)`,
amount: kmAmount,
unitAmount: perKmRate!,
unit: 'PER_KM',
quantity: roadKm,
currency: child.paymentCurrency,
});
total += kmAmount;
}
}
await manager.getRepository(Booking).update(childId, {
totalAmount: total,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
} catch (err) {
this.logger.error(
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The live PER_KM rate value for road billing, in the given currency. */
private async findPerKmRate(currency: string): Promise<number | null> {
const rates = await this.ratesService.findLiveRates();
const rate = rates.find(
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
);
return rate ? Number(rate.rateValue) : null;
}
private async userOwnsContract(
userId: string,
contract: Booking,
): Promise<boolean> {
if (!contract.companyId) return true; // government / staff-created
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(
userId,
);
return company.id === contract.companyId;
} catch {
return false;
}
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.ordersRepository.countByYear(year);
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private async generateChildBookingReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -1,50 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
/** A single contracted/ordered/remaining pool line for a general contract. */
export class ContractQuantityLineView {
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
unitOfMeasure!: CargoUnitOfMeasure | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
}
/**
* A contracted route (lane) of a general contract. Routes are pure
* origin→destination lanes the contract covers; they carry NO quantity. The
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
* and an order picks one lane (for scheduling/billing) while drawing from that
* shared pool.
*/
export class ContractRouteLineView {
@ApiProperty({ description: 'Contract route line id' })
routeLineId!: string;
@ApiProperty()
originYardId!: string;
@ApiProperty({ nullable: true })
originYardName!: string | null;
@ApiProperty()
destinationYardId!: string;
@ApiProperty({ nullable: true })
destinationYardName!: string | null;
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
km!: number | null;
}

View File

@@ -1,28 +0,0 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
/**
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
* when omitted and coerce string inputs (defensive) to numbers.
*/
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
const toDto = (plain: Record<string, unknown>) =>
plainToInstance(CreateBookingOrderLineDto, plain, {
enableImplicitConversion: false,
exposeDefaultValues: true,
}) as unknown as CreateBookingOrderLineDto;
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
const dto = toDto({ quantity: 5 });
expect(dto.hazardousQuantity).toBe(0);
expect(dto.reeferQuantity).toBe(0);
});
it('coerces provided string quantities to numbers', () => {
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
expect(dto.quantity).toBe(5);
expect(dto.hazardousQuantity).toBe(2);
expect(dto.reeferQuantity).toBe(3);
});
});

View File

@@ -1,75 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class CreateBookingOrderLineDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number = 0;
@ApiPropertyOptional({
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number = 0;
}
export class CreateBookingOrderDto {
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
@IsUUID()
contractBookingId!: string;
@ApiPropertyOptional({
format: 'uuid',
description:
'For multi-route contracts: the contract route line being drawn from. ' +
'Determines the shipment origin/destination. Omit for single-route contracts.',
})
@IsOptional()
@IsUUID()
routeLineId?: string;
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ type: [CreateBookingOrderLineDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingOrderLineDto)
lines!: CreateBookingOrderLineDto[];
}

View File

@@ -1,65 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* Postgres `numeric` columns are serialized to JS strings by the driver. This
* transformer hydrates them back into real numbers so consumers (and the
* `quantity: number` API type) don't have to coerce on every read.
*/
const numericColumn = {
to: (value: number) => value,
from: (value: string | null) => (value == null ? value : Number(value)),
};
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
* single line with a null containerTypeId carries the tons/items.
*/
@Entity({ schema: 'freight', name: 'booking_order_lines' })
export class BookingOrderLine extends BaseEntity {
@Column({ name: 'order_id', type: 'uuid' })
orderId!: string;
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order?: BookingOrder;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
quantity!: number;
/**
* How much of this line is hazardous / refrigerated, entered per order by the
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
*/
@Column({
name: 'hazardous_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
hazardousQuantity!: number;
@Column({
name: 'reefer_quantity',
type: 'numeric',
precision: 12,
scale: 3,
default: 0,
transformer: numericColumn,
})
reeferQuantity!: number;
}

View File

@@ -1,70 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { BookingOrderLine } from './booking-order-line.entity';
/**
* A single drawdown against a general contract. Each order spawns its own
* ONE_TIME child Booking (the shipment that enters the train scheduling
* pipeline); this row is the ledger entry linking the contract to that
* shipment and recording the drawn-down quantities.
*/
@Entity({ schema: 'freight', name: 'booking_orders' })
export class BookingOrder extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
/** The ONE_TIME child shipment booking spawned for this order. */
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Denormalized from the contract for fast company-scoped filtering. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The contract route line this order drew down (multi-route general contracts).
* Null for legacy/single-route contracts that have no route lines — the order
* then uses the contract's own origin/destination.
*/
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
routeLineId?: string | null;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
status!: string;
@Column({
name: 'scheduling_status',
type: 'varchar',
length: 30,
default: SchedulingStatus.NotScheduled,
})
schedulingStatus!: string;
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
lines?: BookingOrderLine[];
}

View File

@@ -1,61 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { Yard } from '../../rule-engine/entities/yard.entity';
/**
* One contracted route+quantity line of a GENERAL contract. A general contract
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
* route reserves its own quantity pool. Drawdown orders pick one of these routes
* and decrement that route's pool. One-time bookings do not use this — they keep
* the single origin/destination on the booking itself.
*/
@Entity({ schema: 'freight', name: 'contract_route_lines' })
@Index(['contractBookingId'])
export class ContractRouteLine extends BaseEntity {
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
/**
* Container type this route line reserves (CONTAINER contracts); null for
* BULK/BREAK_BULK, where the quantity is tons/items.
*/
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Contracted quantity for this (route, container type): containers, tons, or items. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
/**
* Road distance for this route, configured with the route. Road (truck)
* drawdown orders bill KM × the PER_KM rate from this value. Null for
* rail-only routes where KM is not billed.
*/
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
km?: number | null;
}

View File

@@ -1,201 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingOrder } from './entities/booking-order.entity';
import { ContractRouteLine } from './entities/contract-route-line.entity';
import {
ContractQuantityLineView,
ContractRouteLineView,
} from './dto/contract-view.dto';
/** Setting code holding the global ordering window (in months) for general contracts. */
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
/** Fallback when the setting is missing or unparseable. */
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/**
* Owns general-contract concerns that sit alongside the generic booking flow:
* the configurable ordering period, post-payment activation, and computing the
* remaining drawdown pool per contract.
*/
@Injectable()
export class GeneralContractService {
private readonly logger = new Logger(GeneralContractService.name);
constructor(
private readonly dataSource: DataSource,
private readonly dropdownSettings: DropdownSettingsService,
) {}
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
return booking.bookingType === BookingType.GeneralContract;
}
/** The configured ordering window in months (defaults to 3). */
async getPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const raw = setting.children?.[0]?.value;
const months = Number(raw);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded yet — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
/**
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
* entering the train queue like a one-time booking) and stamp the ordering
* window. Idempotent.
*/
async activateAfterPayment(bookingId: string): Promise<void> {
const repo = this.dataSource.getRepository(Booking);
const booking = await repo.findOne({ where: { id: bookingId } });
if (!booking || !this.isGeneralContract(booking)) return;
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
return;
}
const months = await this.getPeriodMonths();
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + months);
await repo.update(bookingId, {
status: 'CONTRACT_ACTIVE',
paymentStatus: 'PAID',
expiresAt,
});
this.logger.log(
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
);
}
/**
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
* per container type for CONTAINER contracts, or a single total line for
* BULK/BREAK_BULK (keyed on a null container type).
*/
async getQuantityLines(
contractBookingId: string,
): Promise<ContractQuantityLineView[]> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: contractBookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
const ordered = await this.orderedByContainerType(contractBookingId);
if (booking.freightType === 'CONTAINER') {
return (booking.bookingContainers ?? []).map((c) => {
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
const contracted = Number(c.quantity);
return {
containerTypeId: c.containerTypeId ?? null,
containerTypeName: c.containerType?.label ?? null,
unitOfMeasure: null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
};
});
}
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
const orderedQty = ordered.get('') ?? 0;
const contracted = Number(booking.cargoTotalWeightVgm);
const uom: CargoUnitOfMeasure | null =
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
CargoUnitOfMeasure.PerTon;
return [
{
containerTypeId: null,
containerTypeName: null,
unitOfMeasure: uom,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
},
];
}
/**
* The contracted routes (lanes) of a multi-route general contract — pure
* origin→destination pairs the contract covers. Routes carry NO quantity; the
* contract draws from a single shared pool ({@link getQuantityLines}). An order
* picks one lane (for scheduling + road billing) and draws from that pool.
* Returns [] for single-route contracts (no route lines) — callers then use the
* contract's own origin/destination.
*/
async getRouteLines(
contractBookingId: string,
): Promise<ContractRouteLineView[]> {
const routeLines = await this.dataSource
.getRepository(ContractRouteLine)
.find({
where: { contractBookingId },
relations: {
originYard: true,
destinationYard: true,
},
order: { createdAt: 'ASC' },
});
return routeLines.map((rl) => ({
routeLineId: rl.id,
originYardId: rl.originYardId,
originYardName: rl.originYard?.label ?? null,
destinationYardId: rl.destinationYardId,
destinationYardName: rl.destinationYard?.label ?? null,
km: rl.km != null ? Number(rl.km) : null,
}));
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
private async orderedByContainerType(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.setParameter('empty', '')
.groupBy('key')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) map.set(row.key ?? '', Number(row.total));
return map;
}
/** Convenience: how many units remain for a given container type ('' = bulk). */
async remainingFor(
contractBookingId: string,
containerTypeKey: string,
): Promise<number> {
const lines = await this.getQuantityLines(contractBookingId);
const line = lines.find(
(l) => (l.containerTypeId ?? '') === containerTypeKey,
);
return line?.remainingQuantity ?? 0;
}
/**
* True once the contract's shared pool is fully drawn down. Routes are pure
* lanes with no quantity, so exhaustion is purely a function of the shared
* per-container-type (or bulk) pool, regardless of how many routes exist.
*/
async isExhausted(contractBookingId: string): Promise<boolean> {
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}
}

View File

@@ -1,32 +0,0 @@
import { isRoadService, roadKmPrice } from './road.util';
describe('road.util', () => {
describe('isRoadService', () => {
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
expect(isRoadService({ code: 'ROAD' })).toBe(true);
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
});
it('treats rail / unknown / missing services as not road', () => {
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
expect(isRoadService(null)).toBe(false);
expect(isRoadService(undefined)).toBe(false);
});
});
describe('roadKmPrice', () => {
it('multiplies distance by the per-km rate', () => {
expect(roadKmPrice(120, 5)).toBe(600);
});
it('returns 0 when km or rate is missing/non-positive', () => {
expect(roadKmPrice(null, 5)).toBe(0);
expect(roadKmPrice(120, null)).toBe(0);
expect(roadKmPrice(0, 5)).toBe(0);
expect(roadKmPrice(120, 0)).toBe(0);
});
});
});

View File

@@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
/**
* Default ordering window (months) for a general contract activated on
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
* defined locally to avoid a circular module dependency on booking-orders.
*/
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@@ -240,23 +234,9 @@ export class BookingContractService {
includesCustoms,
);
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else if (isGeneralContract) {
// A general contract is NOT paid up front — each drawdown order is priced
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
// opens its ordering window; orders spawn their own priced child bookings.
const expiresAt = new Date(now);
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
updates.status = 'CONTRACT_ACTIVE';
updates.expiresAt = expiresAt;
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;

View File

@@ -121,9 +121,18 @@ export class BookingPricingService {
total += line.amount;
}
// First / last mile trucking — billed per the rate's unit (km / container /
// ton / flat), only for legs the booking actually carries.
const { lineItems: mileLines, usedRates: mileRates } =
await this.computeFirstLastMileLines(booking, evalInput);
for (const line of mileLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
@@ -424,6 +433,97 @@ export class BookingPricingService {
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/**
* First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
* billed only when the booking carries that leg (an address is set) and a LIVE
* rate exists, scaled by the rate's own unit:
* PER_KM → contract-route road distance (km)
* PER_CONTAINER → total container count
* PER_TON → total bulk tonnage
* FLAT → once
* A leg whose rate value (or computed amount) is 0 contributes nothing.
*/
private async computeFirstLastMileLines(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
{
rateType: 'FIRST_MILE',
label: 'First mile (pick-up)',
active: Boolean(booking.firstMilePickupAddress),
},
{
rateType: 'LAST_MILE',
label: 'Last mile (delivery)',
active: Boolean(booking.lastMileDeliveryAddress),
},
];
if (!legs.some((l) => l.active)) {
return { lineItems: [], usedRates: [] };
}
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const containerCount = evalInput.containers.reduce(
(sum, c) => sum + Number(c.quantity || 0),
0,
);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
for (const leg of legs) {
if (!leg.active) continue;
const rate = liveRates.find(
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
);
if (!rate) continue;
const value = Number(rate.rateValue);
let quantity: number;
switch (rate.rateUnit) {
case 'PER_KM':
quantity = routeKm;
break;
case 'PER_CONTAINER':
quantity = containerCount;
break;
case 'PER_TON':
quantity = bulkTons;
break;
case 'FLAT':
default:
quantity = 1;
break;
}
const usdAmount = value * quantity;
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
if (!(usdAmount > 0)) continue;
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const unitUsd = value;
usedRatesMap.set(rate.id, rate);
lines.push({
code: leg.rateType,
description: leg.label,
amount,
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
unit: rate.rateUnit,
quantity,
currency: paymentCurrency,
});
}
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
/** Friendly container-type label for the per-unit card; degrades to "Container". */
private async containerTypeLabel(containerTypeId: string): Promise<string> {
try {

View File

@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
@@ -78,18 +77,4 @@ describe('BookingTransitionService — operation review', () => {
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
);
});
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
amount: 1500,
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
adjustedTotalAmount: 1500,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
}),
);
});
});

View File

@@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from '../booking-orders/road.util';
import { isRoadService } from './road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -477,30 +477,6 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**
@@ -861,17 +837,17 @@ export class BookingTransitionService {
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Three outcomes:
* route). Two outcomes:
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
* before the booking can enter the pool.
*
* The booking price is computed from the contract and is never adjusted here.
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
decision: 'ACCEPT' | 'REQUEST_CHANGES',
actorId: string,
options: { note?: string; amount?: number } = {},
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
@@ -894,48 +870,10 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
if (decision === 'ADJUST_PRICE') {
if (options.amount == null || options.amount < 0) {
throw new BadRequestException(
'A non-negative adjusted amount is required to adjust the price',
);
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: options.amount,
adjustedByStaffId: actorId,
adjustedAt: new Date(),
adjustmentReason: options.note ?? null,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
} as never);
return this.bookingsService.findById(bookingId);
}
// ACCEPT — enter the batch holding pool.
return this.acceptOperationRequest(booking);
}
/**
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
* pushes the booking into the pool; rejecting returns it to the customer as an
* operation change request so they can resubmit or cancel.
*/
async confirmOperationPrice(
bookingId: string,
accept: boolean,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
if (!accept) {
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(bookingId);
}
return this.acceptOperationRequest(booking);
}
/**
* Move a reviewed operation request forward after Marketing accepts.
*

View File

@@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
@@ -52,7 +51,6 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
ConfirmOperationPriceDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
@@ -404,24 +402,7 @@ export class BookingsController {
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note, amount: dto.amount },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/confirm-price')
@ApiOperation({
summary:
'Customer confirms or rejects an operations price adjustment ' +
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
})
async confirmOperationPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConfirmOperationPriceDto,
) {
const booking = await this.transitionService.confirmOperationPrice(
id,
dto.accept,
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -521,25 +502,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import {
@@ -34,7 +35,6 @@ export interface BookingListFilterOptions {
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -164,6 +164,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
return Number(result?.total ?? 0);
}
/**
* The road billing distance (km) of a booking's contract route, used to price
* per-km first/last-mile trucking. Returns 0 when there is no route or no km
* recorded (rail-only lanes) so a PER_KM rate bills nothing.
*/
async getContractRouteKm(contractRouteId: string | null | undefined): Promise<number> {
if (!contractRouteId) return 0;
const route = await this.dataSource
.getRepository(ContractRoute)
.findOne({ where: { id: contractRouteId }, select: { id: true, km: true } });
return Number(route?.km ?? 0);
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
@@ -693,11 +706,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.booking_type = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
createdFrom: options.createdFrom,

View File

@@ -3,6 +3,7 @@ import {
ConflictException,
ForbiddenException,
forwardRef,
GoneException,
Inject,
Injectable,
NotFoundException,
@@ -27,7 +28,6 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
@@ -283,6 +283,15 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// Contractbooking separation: contracts are no longer created through the
// booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
// must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
if (dto.bookingType === 'GENERAL_CONTRACT') {
throw new GoneException(
'General contracts are no longer created here. Use POST /contracts instead.',
);
}
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
@@ -295,7 +304,6 @@ export class BookingsService {
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
@@ -442,7 +450,6 @@ export class BookingsService {
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
@@ -469,7 +476,6 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
@@ -496,27 +502,6 @@ export class BookingsService {
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
// Multi-route general contracts: persist the contracted routes (lanes). Routes
// carry NO quantity — the contract has a single shared pool (the cargo-step
// total / container quantities). Each drawdown order picks one lane for
// scheduling + road billing and draws from that shared pool. `quantity` on the
// route line is retained for legacy rows but is no longer meaningful (0).
if (isGeneralContract && dto.routes?.length) {
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
await routeRepo.save(
dto.routes.map((r) =>
routeRepo.create({
contractBookingId: booking.id,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
containerTypeId: null,
quantity: 0,
km: r.km ?? null,
}),
),
);
}
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, 'bookings', files);
@@ -808,7 +793,6 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
@@ -1017,7 +1001,6 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,

View File

@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
outputCode: string | null;
includesCustoms: boolean;
} {
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation
// isn't loaded / has includesCustoms=false — without this the per-booking
// clearance grid would resolve empty.
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -1,10 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
@@ -70,22 +68,6 @@ export class RejectBookingDto {
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()
@@ -117,12 +99,12 @@ export class OperationReviewDto {
@ApiProperty({
description:
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
'customer must re-confirm before it proceeds.',
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
'returns it to the customer with a note. The booking price is computed ' +
'from the contract and cannot be adjusted by staff.',
enum: ['ACCEPT', 'REQUEST_CHANGES'],
})
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
@ApiPropertyOptional({
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
@@ -130,22 +112,4 @@ export class OperationReviewDto {
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: 'New total price — required for ADJUST_PRICE.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
}
export class ConfirmOperationPriceDto {
@ApiProperty({
description:
'true to accept the operations price adjustment and proceed to the ' +
'batch pool; false to reject it (returns to operation changes requested).',
})
@IsBoolean()
accept!: boolean;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BookingContainer } from './booking-container.entity';
/**
* One physical container under a booking_container line — its number, seal, and
* per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET
* in Path B). See §5.10.
*/
@Entity({ schema: 'freight', name: 'booking_container_units' })
@Index(['bookingContainerId'])
export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'booking_container_id', type: 'uuid' })
bookingContainerId!: string;
@ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_container_id' })
bookingContainer?: BookingContainer;
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
@Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true })
sealNumber?: string | null;
@Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 })
vgmTons!: number;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
/** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
containerSize?: string | null;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;
/** How many units of this line are hazardous (≤ quantity). */
@Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 })
hazardousQuantity!: number;
/** How many units of this line are refrigerated (≤ quantity). */
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
reeferQuantity!: number;
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
vgmPerUnitTons!: number;

View File

@@ -144,13 +144,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
/**
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
* umbrella contract that is signed/paid once and then drawn down by many
* orders (each order spawns its own ONE_TIME child booking).
*/
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/** The contract this shipment booking was created under (contractbooking split). */
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
contractId?: string | null;
/** The contract route (lane) this shipment uses. */
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
contractRouteId?: string | null;
/** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
contractKind?: string | null;
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
createdByRole?: string | null;
@Column({ name: 'created_by_user_id', type: 'uuid', nullable: true })
createdByUserId?: string | null;
/**
* Nullable: general contracts have no shipment date at creation — the date is
@@ -220,13 +231,6 @@ export class Booking extends BaseEntity {
@Column({ name: 'contract_type', type: 'varchar', length: 20 })
contractType!: string;
@Column({ name: 'previous_contract_id', type: 'uuid', nullable: true })
previousContractId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'previous_contract_id' })
previousContract?: Booking | null;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@@ -422,6 +426,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
// ── Global Logistics station routing (GL Import/Export US-02) ──────────────
/** Origin-station yard the shipment is routed to for GL handling. */
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
glStationYardId?: string | null;
/** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null;
@Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true })
glAssignedAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];

View File

@@ -0,0 +1,45 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingRequest } from './entities/booking-request.entity';
@Injectable()
export class BookingRequestRepository extends BaseRepository<BookingRequest> {
constructor(
@InjectRepository(BookingRequest)
repository: Repository<BookingRequest>,
) {
super(repository);
}
/** All requests on a contract, newest first. */
async findForContract(contractId: string): Promise<BookingRequest[]> {
return this.repository.find({
where: { contractId },
order: { createdAt: 'DESC' },
});
}
/** GL queue: pending requests across all contracts, oldest first. */
async findPending(): Promise<BookingRequest[]> {
return this.repository.find({
where: { status: 'PENDING' },
order: { createdAt: 'ASC' },
relations: { contract: true },
});
}
async findById(id: string): Promise<BookingRequest | null> {
return this.repository.findOne({
where: { id },
relations: { contract: true },
});
}
/** Total rows — used to mint the next sequential reference. */
async count(): Promise<number> {
return this.repository.count();
}
}

View File

@@ -0,0 +1,199 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
import { BookingRequest } from './entities/booking-request.entity';
import { Contract } from './entities/contract.entity';
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
/**
* Customer shipment requests on GENERAL customs (Path B) contracts. The customer
* submits a request (date + quantities); GL reviews the queue and, on accept,
* creates the booking — after which per-booking clearance begins. ONE_TIME and
* Path A do not use this flow.
*/
@Injectable()
export class BookingRequestService {
constructor(
private readonly repo: BookingRequestRepository,
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
) {}
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
private assertGeneralCustoms(contract: Contract): void {
if (
contract.contractKind !== 'GENERAL' ||
!contract.customsClearingEnabled
) {
throw new BadRequestException(
'Shipment requests apply only to general customs-clearance contracts.',
);
}
}
/** Customer submits a shipment request. */
async submit(
contractId: string,
dto: CreateBookingRequestDto,
userId?: string,
): Promise<BookingRequest> {
const contract = await this.contractsService.findById(contractId);
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
this.assertGeneralCustoms(contract);
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new ConflictException(
'The contract must be active before requesting a shipment.',
);
}
const isContainer = contract.freightType === 'CONTAINER';
const hasLines = isContainer
? (dto.containers?.length ?? 0) > 0
: Boolean(dto.bulk);
if (!hasLines) {
throw new BadRequestException(
isContainer
? 'Add at least one container line.'
: 'Enter the bulk cargo amount.',
);
}
// Validate requested container sizes against the contract cargo scope and
// remaining draw-down capacity (reuses the booking quantity-cap check).
if (isContainer) {
const allowed = new Set(
(contract.cargoScope ?? [])
.map((s) => s.containerSize)
.filter((s): s is string => !!s),
);
for (const line of dto.containers ?? []) {
if (allowed.size && !allowed.has(line.containerSize)) {
throw new BadRequestException(
`Container size ${line.containerSize} is not in this contract's scope.`,
);
}
}
}
await this.contractBookingService.assertRequestWithinCapacity(contract, {
containers: dto.containers,
bulk: dto.bulk,
});
const requestedLines: Freight.RequestedShipmentLines = isContainer
? {
containers: (dto.containers ?? []).map((l) => ({
containerSize: l.containerSize,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity,
reeferQuantity: l.reeferQuantity,
})),
}
: {
bulk: {
cargoTypeId: dto.bulk?.cargoTypeId ?? null,
cargoWeightTons: dto.bulk?.cargoWeightTons,
itemCount: dto.bulk?.itemCount,
hazardousQuantity: dto.bulk?.hazardousQuantity,
},
};
const reference = await this.generateReference();
return this.repo.create({
reference,
contractId,
requestedByUserId: userId ?? null,
contractRouteId: dto.contractRouteId ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
status: 'PENDING',
requestedLines,
notes: dto.notes ?? null,
} as never);
}
listForContract(contractId: string): Promise<BookingRequest[]> {
return this.repo.findForContract(contractId);
}
async findOne(requestId: string): Promise<BookingRequest> {
const request = await this.repo.findById(requestId);
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
return request;
}
queue(): Promise<BookingRequest[]> {
return this.repo.findPending();
}
private async findPending(requestId: string): Promise<BookingRequest> {
const request = await this.repo.findById(requestId);
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
if (request.status !== 'PENDING') {
throw new ConflictException(
`This request is already ${request.status.toLowerCase()}.`,
);
}
return request;
}
/**
* GL accepts a request. The booking itself is created via the GL booking form
* (POST /contracts/:id/bookings) which carries the per-unit container data the
* request omits; this endpoint records the acceptance + links the created
* booking. `bookingId` is supplied by the GL form on success.
*/
async accept(
requestId: string,
bookingId: string,
staffId?: string,
): Promise<BookingRequest> {
const request = await this.findPending(requestId);
await this.repo.update(requestId, {
status: 'ACCEPTED',
createdBookingId: bookingId,
reviewedByStaffId: staffId ?? null,
reviewedAt: new Date(),
} as never);
return (await this.repo.findById(requestId)) ?? request;
}
/** GL rejects a request with a note. */
async reject(
requestId: string,
note?: string,
staffId?: string,
): Promise<BookingRequest> {
const request = await this.findPending(requestId);
await this.repo.update(requestId, {
status: 'REJECTED',
reviewNote: note ?? null,
reviewedByStaffId: staffId ?? null,
reviewedAt: new Date(),
} as never);
return (await this.repo.findById(requestId)) ?? request;
}
/** Customer cancels their own pending request. */
async cancel(requestId: string, userId?: string): Promise<BookingRequest> {
const request = await this.findPending(requestId);
if (request.requestedByUserId && request.requestedByUserId !== userId) {
throw new ForbiddenException('You can only cancel your own requests.');
}
await this.repo.update(requestId, { status: 'CANCELLED' } as never);
return (await this.repo.findById(requestId)) ?? request;
}
private async generateReference(): Promise<string> {
const count = await this.repo.count();
const seq = String(count + 1).padStart(6, '0');
return `SR-${seq}`;
}
}

View File

@@ -0,0 +1,100 @@
import { IMPORT_MILESTONES, EXPORT_MILESTONES } from '@edr/types';
import type { MilestoneOwnerRegion } from './entities/clearance-milestone.entity';
/**
* Static catalog of clearance milestones per trade direction (doc §11.3, §12.2).
* Drives the rows seeded onto a contract clearance cycle (pre-booking) and
* booking (post-booking). `phaseBoundaryAfter` marks the last pre-booking
* milestone — everything after it tracks on the booking.
*/
export interface MilestoneDef {
code: string;
label: string;
ownerRegion: MilestoneOwnerRegion;
triggeredByDoc: boolean;
}
const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'DJ', triggeredByDoc: false },
LOADED: { label: 'Loaded', ownerRegion: 'DJ', triggeredByDoc: false },
DEPARTED_FROM_DJIBOUTI: { label: 'Departed from Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
ARRIVED_ETHIOPIA: { label: 'Arrived at Port in Ethiopia', ownerRegion: 'OPS', triggeredByDoc: false },
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
EXIT_NOTE_GENERATED: { label: 'Exit Note Generated', ownerRegion: 'OPS', triggeredByDoc: true },
};
const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
EXPORT_DOCS_UPLOADED: { label: 'Export Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
RELEASE_ORDER_SECURED: { label: 'Release Order Secured', ownerRegion: 'DJ', triggeredByDoc: true },
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
EXPORT_RELEASED: { label: 'Export Released', ownerRegion: 'ET', triggeredByDoc: false },
WAGON_REQUESTED: { label: 'Wagon Requested', ownerRegion: 'ET', triggeredByDoc: false },
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
};
/**
* Codes BEFORE (and including) this one are pre-booking — they attach to the
* contract clearance cycle. From the next code onward, milestones attach to the
* booking GL creates. Per doc §11.3 the booking is created right after DO_COLLECTED
* (import) / EXPORT_RELEASED (export), i.e. before WAGON_REQUESTED.
*/
const IMPORT_PRE_BOOKING_LAST = 'DO_COLLECTED';
const EXPORT_PRE_BOOKING_LAST = 'EXPORT_RELEASED';
function buildDefs(
codes: readonly string[],
defs: Record<string, Omit<MilestoneDef, 'code'>>,
): MilestoneDef[] {
return codes.map((code) => ({ code, ...defs[code] }));
}
export function milestonesForDirection(tradeDirection: string): MilestoneDef[] {
if (tradeDirection === 'IMPORT') return buildDefs(IMPORT_MILESTONES, IMPORT_DEFS);
if (tradeDirection === 'EXPORT') return buildDefs(EXPORT_MILESTONES, EXPORT_DEFS);
return [];
}
/** Split the milestone list into pre-booking (contract) and post-booking (booking). */
export function splitMilestones(tradeDirection: string): {
preBooking: MilestoneDef[];
postBooking: MilestoneDef[];
} {
const all = milestonesForDirection(tradeDirection);
const boundary =
tradeDirection === 'IMPORT' ? IMPORT_PRE_BOOKING_LAST : EXPORT_PRE_BOOKING_LAST;
const idx = all.findIndex((m) => m.code === boundary);
if (idx < 0) return { preBooking: all, postBooking: [] };
return { preBooking: all.slice(0, idx + 1), postBooking: all.slice(idx + 1) };
}
/** The handoff milestone that flips primary ownership ET ↔ DJ (doc §11.5/§12.3). */
export const HANDOFF_MILESTONES = ['DEPARTED_FROM_DJIBOUTI', 'DEPARTED_TO_DJIBOUTI'];

View File

@@ -0,0 +1,223 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
ClearanceMilestone,
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
MilestoneDef,
milestonesForDirection,
splitMilestones,
} from './clearance-milestone.catalog';
/**
* Seeds and advances the GL clearance milestones (1823 per direction). Pre-booking
* milestones attach to the contract clearance cycle; post-booking milestones attach
* to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2.
*/
@Injectable()
export class ClearanceMilestoneService {
constructor(private readonly dataSource: DataSource) {}
private get repo() {
return this.dataSource.getRepository(ClearanceMilestone);
}
/** Seed the pre-booking milestones onto a contract's current clearance cycle. */
async seedPreBookingMilestones(
contract: Contract,
clearanceCycleId: string,
): Promise<void> {
const { preBooking } = splitMilestones(contract.tradeDirection);
await this.seed(preBooking, {
contractId: contract.id,
clearanceCycleId,
});
}
/** Seed the post-booking milestones onto a freshly created booking. */
async seedPostBookingMilestones(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const { postBooking } = splitMilestones(tradeDirection);
await this.seed(postBooking, { bookingId });
}
private async seed(
defs: MilestoneDef[],
scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string },
): Promise<void> {
if (!defs.length) return;
const rows = defs.map((def, i) =>
this.repo.create({
...scope,
milestoneCode: def.code,
milestoneLabel: def.label,
ownerRegion: def.ownerRegion,
triggeredByDoc: def.triggeredByDoc,
status: 'PENDING',
sortOrder: i,
}),
);
await this.repo.save(rows);
}
/** List milestones for a contract cycle or a booking. */
async listForContract(contractId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({
where: { contractId },
order: { sortOrder: 'ASC' },
});
}
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.repo.find({
where: { bookingId },
order: { sortOrder: 'ASC' },
});
}
/** Mark a milestone complete (by code) on a booking. */
async completeForBooking(
bookingId: string,
code: string,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
}
if (milestone.status === 'COMPLETED') {
throw new BadRequestException(`Milestone ${code} is already completed.`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
if (note) milestone.note = note;
const saved = await this.repo.save(milestone);
if (HANDOFF_MILESTONES.includes(code)) {
await this.onHandoff(bookingId, code);
}
return saved;
}
/**
* Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED
* milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the
* milestone metadata so the timeline shows it.
*/
async assignRisk(
bookingId: string,
riskLevel: CustomsRiskLevel,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/**
* Advise duty & tax (amount + declaration serial) and complete the
* DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the
* payment slip, which doc-triggers DUTY_TAX_PAID.
*/
async adviseDuty(
bookingId: string,
input: { amount: number; currency: string; declarationSerial?: string },
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadata(
bookingId,
'DUTY_TAXES_ADVISED',
{
dutyAmount: input.amount,
dutyCurrency: input.currency,
declarationSerial: input.declarationSerial,
},
userId,
note,
);
}
/** Complete a milestone and merge structured metadata onto it. */
private async completeWithMetadata(
bookingId: string,
code: string,
metadata: MilestoneMetadata,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
if (note) milestone.note = note;
return this.repo.save(milestone);
}
/** Mark a pre-booking milestone complete (by code) on a contract cycle. */
async completeForContract(
contractId: string,
code: string,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
}
if (milestone.status === 'COMPLETED') {
throw new BadRequestException(`Milestone ${code} is already completed.`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
if (note) milestone.note = note;
return this.repo.save(milestone);
}
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
async completeByDocTrigger(
scope: { bookingId?: string; contractId?: string },
code: string,
): Promise<void> {
const where = scope.bookingId
? { bookingId: scope.bookingId, milestoneCode: code }
: { contractId: scope.contractId, milestoneCode: code };
const milestone = await this.repo.findOne({ where });
if (!milestone || milestone.status === 'COMPLETED') return;
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
await this.repo.save(milestone);
}
/**
* ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the
* lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL
* Djibouti. Notifications are handled by the notification layer (out of scope);
* here we only record the ownership flip on subsequent pending milestones.
*/
private async onHandoff(bookingId: string, code: string): Promise<void> {
void bookingId;
void code;
// Ownership region is already encoded per-milestone in the catalog; no
// mutation is required. This hook exists for the notification dispatch that
// the GL US-09 handoff requires once the notification module lands.
}
/** Catalog passthrough for the frontend timeline (labels + owners). */
catalogForDirection(tradeDirection: string): MilestoneDef[] {
return milestonesForDirection(tradeDirection);
}
}

View File

@@ -0,0 +1,561 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
}
/**
* The single create path for shipment bookings under a contract.
*
* - Path A (transport only): the customer creates the booking once the contract
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
* shipment data.
*
* From booking creation onward the existing batch/payment/allocation pipeline
* runs unchanged. See docs/new-doc.md §8, §13.
*/
@Injectable()
export class ContractBookingService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dataSource: DataSource,
) {}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
if (contract.contractKind === 'ONE_TIME') {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
} else {
// GENERAL: draw down against the cargo quantity cap until it is full.
await this.assertWithinQuantityCap(contract, dto);
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const reference = await this.generateReference();
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
// operations, and there is NO contract-level clearance cycle to link.
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
await this.persistContainers(booking.id, contract, dto);
}
// Reload with containers to compute the total from contract unit rates × qty.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
// cycle to this booking, seed post-booking milestones, and lock the contract
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
if (contract.customsClearingEnabled && !generalCustoms) {
const cycle = await this.contractsRepository.currentCycle(contract.id);
if (cycle) {
await this.contractsRepository.linkBooking(cycle.id, booking.id);
}
await this.milestoneService.seedPostBookingMilestones(
booking.id,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never);
} else if (generalCustoms) {
// Per-booking clearance: seed post-booking milestones on the booking (no
// cycle needed) and leave the contract active. The booking now drives its
// own clearance via the booking-level pipeline.
await this.milestoneService.seedPostBookingMilestones(
booking.id,
contract.tradeDirection,
);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
return { booking: result ?? booking, warnings };
}
/**
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
if (contract.customsClearingEnabled) {
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
// The customer never books a customs contract himself.
if (!isGlActor) {
throw new ForbiddenException(
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
);
}
if (contract.contractKind === 'GENERAL') {
// GENERAL customs has NO contract clearance cycle — GL books per accepted
// shipment request while the contract is active; clearance is per booking.
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
'Contract must be active to book a shipment.',
);
}
return 'GL_ET';
}
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
throw new BadRequestException(
'Contract clearance is not ready for booking yet.',
);
}
return 'GL_ET';
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
}
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
.getCount();
}
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/**
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
*/
/**
* Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
* {@link assertWithinQuantityCap} but reads the request's quantity shape.
*/
async assertRequestWithinCapacity(
contract: Contract,
lines: {
containers?: Array<{ containerSize: string; quantity: number }>;
bulk?: { cargoWeightTons?: number; itemCount?: number };
},
): Promise<void> {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped contract
if (contract.freightType === 'CONTAINER') {
for (const line of lines.containers ?? []) {
const cap = capacity.find((c) => c.containerSize === line.containerSize);
if (!cap || cap.remaining == null) continue;
if (line.quantity > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
);
}
}
} else {
const requested =
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
);
}
}
}
private async assertWithinQuantityCap(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped contract
if (contract.freightType === 'CONTAINER') {
for (const line of dto.containers ?? []) {
const cap = capacity.find((c) => c.containerSize === line.containerSize);
if (!cap || cap.remaining == null) continue; // size uncapped
if (line.quantity > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
);
}
}
} else {
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const cap = capacity.find((c) => c.cap != null);
if (cap && cap.remaining != null && requested > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
);
}
}
}
/**
* Remaining bookable quantity per cargo-scope line: cap minus what prior
* bookings already consumed. Returns [] when the contract has no caps.
*/
async computeCapacity(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const scope = contract.cargoScope ?? [];
const capped = scope.filter((s) => s.quantityCap != null);
if (capped.length === 0) return [];
const booked = await this.bookedQuantities(contract);
return capped.map((s) => {
const cap = Number(s.quantityCap);
const used =
contract.freightType === 'CONTAINER'
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
: booked.bulk;
return {
containerSize: s.containerSize,
cargoTypeId: s.cargoTypeId,
cap,
booked: used,
remaining: Math.max(0, cap - used),
};
});
}
/**
* Quantities already booked under a contract that still hold capacity. Excludes
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
*/
private async bookedQuantities(
contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource
.getRepository(BookingContainer)
.createQueryBuilder('bc')
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
.select('bc.container_size', 'size')
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.groupBy('bc.container_size')
.getRawMany<{ size: string | null; qty: string }>();
const bySize = new Map<string, number>();
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
return { bySize, bulk: 0 };
}
const row = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.getRawOne<{ tons: string }>();
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
}
private async resolveRoute(
contract: Contract,
contractRouteId?: string,
): Promise<ContractRoute | null> {
const routes = contract.routes ?? [];
if (contractRouteId) {
const found = routes.find((r) => r.id === contractRouteId);
if (!found) {
throw new BadRequestException('Selected route is not part of this contract.');
}
return found;
}
// ONE_TIME (or single-route GENERAL): auto-select the only route.
if (routes.length === 1) return routes[0];
if (routes.length === 0) return null;
throw new BadRequestException(
'contractRouteId is required for multi-route general contracts.',
);
}
private resolveCargoTypeId(
contract: Contract,
dto: CreateBookingUnderContractDto,
): string | null {
if (contract.freightType === 'BULK') {
const bulk = dto.bulkLines?.[0];
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
if (!dto.bulkLines?.length) return 0;
return dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
0,
);
}
/**
* Map each contract-scope container size to a concrete container type and
* persist the booking_container line + its per-unit container numbers. Weight
* rule results are filled in afterward by {@link applyWeightResults} once all
* lines exist (a single rule-engine pass over the booking).
*/
private async persistContainers(
bookingId: string,
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException('At least one container line is required.');
}
const allowedSizes = new Set(
(contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s),
);
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) {
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`,
);
}
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const vgmPerUnit = line.units.length
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
: 0;
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId,
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
isOverweight: false,
overweightExcessTons: null,
} as Partial<BookingContainer>),
);
let sortOrder = 0;
for (const unit of line.units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
sealNumber: unit.sealNumber ?? null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
sortOrder: sortOrder++,
}),
);
}
}
}
/**
* Run the rule engine once over the freshly-created booking and persist the
* overweight result per container line (same ordering the engine returns).
*/
private async applyWeightResults(booking: Booking): Promise<void> {
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const containers = booking.bookingContainers ?? [];
const containerRepo = this.dataSource.getRepository(BookingContainer);
for (let i = 0; i < containers.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr) continue;
await containerRepo.update(containers[i].id, {
weightLimitRuleId: wr.weightLimitRuleId,
isOverweight: wr.isOverweight,
overweightExcessTons: wr.overweightExcessTons,
});
}
}
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`);
}
if (preferReefer) {
const reefer = types.find((t) => t.isReefer);
if (reefer) return reefer;
}
return types.find((t) => !t.isReefer) ?? types[0];
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
const seq = String(count + 1).padStart(6, '0');
return `BK-${year}-${seq}`;
}
}

View File

@@ -0,0 +1,539 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
export interface ContractClearanceDocument {
fileKey: string;
label: string;
required: boolean;
uploadedBy: 'customer' | 'gl';
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: ContractDocReviewStatus | null;
note: string | null;
/** When the review decision (approve/query) was recorded. */
reviewedAt: string | null;
/** Staff id that recorded the decision (no user directory to resolve names). */
reviewedByStaffId: string | null;
}
export interface ContractClearanceView {
contractId: string;
status: string;
clearanceStatus: string;
cycleNumber: number;
includesCustoms: boolean;
inputCode: string | null;
outputCode: string | null;
documents: ContractClearanceDocument[];
allApproved: boolean;
}
@Injectable()
export class ContractClearanceService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
) {}
/** The pre-booking clearance document grid for a contract (Path B). */
async getClearanceView(contractId: string): Promise<ContractClearanceView> {
const contract = await this.contractsService.findById(contractId);
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
const files = await this.filesService.findByResource(contractId, 'contracts');
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const documents: ContractClearanceDocument[] = [];
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return; // setting not seeded — skip gracefully
}
for (const field of setting.fields ?? []) {
const file = fileByCode.get(field.fileKey) ?? null;
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
documents.push({
fileKey: field.fileKey,
label: field.fileLabel,
required: field.isRequired,
uploadedBy,
settingCode: code,
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
};
await pushSetting(inputCode, 'customer');
await pushSetting(outputCode, 'gl');
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
for (const f of files) {
if (!f.code?.startsWith('custom_')) continue;
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
documents.push({
fileKey: f.code,
label: f.name,
required: false,
uploadedBy: 'customer',
settingCode: 'custom',
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByStaffId: review?.reviewedByStaffId ?? null,
});
}
const allApproved = await this.isClearanceFullyApproved(contract);
return {
contractId,
status: contract.status,
clearanceStatus: contract.clearanceStatus,
cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
};
}
/**
* True when every REQUIRED customer-input field has an APPROVED review row in
* the current cycle. The 100% gate before clearance can be finalized.
*/
private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
const { inputCode } = contractClearanceCodes(contract);
if (!inputCode) return true;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return false;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return true;
const cycle = await this.contractsRepository.currentCycle(contract.id);
const reviews = await this.contractsRepository.findDocumentReviews(
contract.id,
cycle?.id ?? null,
);
return required.every((field) =>
reviews.some(
(r) =>
r.settingCode === inputCode &&
r.fileKey === field.fileKey &&
r.status === 'APPROVED',
),
);
}
/**
* Customer uploads clearance documents on the contract. When every required
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
*/
async uploadDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
contract.status !== 'CLEARANCE_UNDER_REVIEW'
) {
throw new ConflictException(
`Cannot upload clearance documents on status "${contract.status}".`,
);
}
const { inputCode } = contractClearanceCodes(contract);
if (!inputCode) {
throw new BadRequestException('This contract has no document-clearance step');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
// First submission: every required input field must be present.
if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
await this.assertRequiredInputsPresent(contractId, inputCode, files);
}
for (const file of files) {
const record = await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
await this.contractsRepository.upsertDocumentReviewPending({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey: file.fieldname,
fileRecordId: record.id,
uploadedByRole: 'CUSTOMER',
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
return this.contractsService.findById(contractId);
}
private async assertRequiredInputsPresent(
contractId: string,
inputCode: string,
files: Express.Multer.File[],
): Promise<void> {
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(inputCode);
} catch {
return;
}
const required = (setting.fields ?? []).filter((f) => f.isRequired);
if (required.length === 0) return;
const existing = await this.filesService.findByResource(contractId, 'contracts');
const presentKeys = new Set<string>([
...existing.map((f) => f.code),
...files.map((f) => f.fieldname),
]);
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
if (missing.length > 0) {
const labels = missing.map((f) => f.fileLabel).join(', ');
throw new BadRequestException(
`Please upload all required documents before submitting: ${labels}`,
);
}
}
/**
* Path A (no customs) — the customer clears the cargo himself and uploads his
* own clearance proof, reviewed by Operations rather than GL. True when a
* clearance doc set resolves for a non-customs contract.
*/
private isSelfClear(contract: Contract): boolean {
if (contract.customsClearingEnabled) return false;
return contractClearanceCodes(contract).inputCode != null;
}
/** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
async reviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
}
/**
* Operations (Path A) reviews a customer self-clearance document. Identical
* approve/query loop to {@link reviewDocument}; rejects customs (Path B)
* contracts, which are GL-reviewed.
*/
async opsReviewDocument(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations review applies only to self-clearance (non-customs) contracts.',
);
}
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
}
private async applyReview(
contractId: string,
fileKey: string,
status: 'APPROVED' | 'QUERIED',
staffId: string,
reviewerRole: 'GL_ET' | 'OPERATIONS',
note?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
// the contract to "awaiting" (the customer must re-upload), but the reviewer
// may still be working through the rest of the batch. Restricting to
// UNDER_REVIEW only would 409 every review after the first query.
if (
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);
}
if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document');
}
const { inputCode, outputCode } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId);
const reviews = await this.contractsRepository.findDocumentReviews(
contractId,
cycle?.id ?? null,
);
const match = reviews.find((r) => r.fileKey === fileKey);
const settingCode =
match?.settingCode ??
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
await this.contractsRepository.setDocumentReviewStatus({
contractId,
clearanceCycleId: cycle?.id ?? null,
settingCode,
fileKey,
status,
staffId,
note,
});
if (status === 'QUERIED') {
await this.contractsRepository.createReviewNote(
contractId,
`Document "${fileKey}" queried: ${note}`,
'CHANGES_REQUESTED',
staffId,
reviewerRole,
);
// Return the contract to the customer to re-upload the queried document.
await this.contractsRepository.update(contractId, {
status: 'AWAITING_CLEARANCE_DOCUMENTS',
clearanceStatus: 'AWAITING_DOCUMENTS',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
}
}
return this.contractsService.findById(contractId);
}
/** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
async uploadOutputDocuments(
contractId: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot upload output documents on status "${contract.status}".`,
);
}
const { outputCode } = contractClearanceCodes(contract);
if (!outputCode) {
throw new BadRequestException('This contract has no customs output documents');
}
if (files.length === 0) {
throw new BadRequestException('No documents uploaded');
}
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: file.fieldname,
file,
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING
* (GL then creates the booking). Rejects self-clearance (Path A) contracts.
*/
async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (this.isSelfClear(contract)) {
throw new ConflictException(
'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const { outputCode } = contractClearanceCodes(contract);
if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
const files = await this.filesService.findByResource(contractId, 'contracts');
const uploaded = new Set(files.map((f) => f.code));
const missing = (setting.fields ?? []).filter(
(f) => f.isRequired && !uploaded.has(f.fileKey),
);
if (missing.length > 0) {
throw new BadRequestException(
`Upload all required customs output documents first: ${missing
.map((m) => m.fileLabel)
.join(', ')}`,
);
}
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_READY_FOR_BOOKING',
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(
cycle.id,
'CLEARANCE_READY_FOR_BOOKING',
{ clearanceReadyAt: new Date() },
);
}
return this.contractsService.findById(contractId);
}
/**
* Operations finalizes Path A self-clearance: requires every customer document
* APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
* output phase on Path A, so the contract goes straight to FULLY_EXECUTED
* (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
*/
async opsFinalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (!this.isSelfClear(contract)) {
throw new ConflictException(
'Operations finalize applies only to self-clearance (non-customs) contracts.',
);
}
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract);
if (!approved) {
throw new BadRequestException(
'All required documents must be approved before clearance can be finalized',
);
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
clearanceStatus: 'SELF_CLEARED',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
clearanceReadyAt: new Date(),
});
}
return this.contractsService.findById(contractId);
}
/**
* GL ET clearance hub: every customs (Path B) contract that still needs
* customs clearance — awaiting the customer's documents, under GL review, or
* finalized and waiting for the customer to create the booking in the portal.
*/
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
],
customsClearingEnabled: true,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* Operations queue: self-clearance (Path A) contracts awaiting Operations
* review of the customer's own clearance documents.
*/
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** GL ET history: contracts that completed Path B clearance. */
async history(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: true,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
/** Operations history: contracts that completed Path A self-clearance review. */
async opsHistory(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: false,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});
}
}

View File

@@ -0,0 +1,81 @@
import { Contract } from './entities/contract.entity';
/**
* Resolves which seeded clearance FileUploadSetting applies to a contract during
* the CONTRACT pre-booking phase (Path B). Mirrors clearance.util.ts but emits
* `contract_clearance_*` codes keyed on (tradeDirection, freightType, customs).
*/
type Op = 'import' | 'export';
type Freight = 'container' | 'bulk';
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
function operationFor(tradeDirection: string): Op | null {
if (tradeDirection === 'IMPORT') return 'import';
if (tradeDirection === 'EXPORT') return 'export';
return null; // DOMESTIC / intercity — no clearance gate
}
function freightFor(freightType: string): Freight {
return freightType === 'BULK' ? 'bulk' : 'container';
}
/**
* The customer-input clearance setting code, or null when no gate applies.
*
* - Path B (customs bundled): the customer uploads the documents GL needs to do
* the clearance work → `contract_clearance_{op}_{freight}`.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`,
* reviewed by Operations rather than GL.
*
* DOMESTIC/intercity has no border, so no clearance gate applies on either path.
*/
export function contractClearanceSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
const op = operationFor(tradeDirection);
if (!op) return null;
const freight = freightFor(freightType);
if (!includesCustoms) {
return `contract_clearance_selfclear_${op}_${freight}`;
}
return `contract_clearance_${op}_${freight}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
export function contractClearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
includesCustoms: boolean,
): string | null {
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
if (freightFor(freightType) !== 'container') return null;
return `contract_clearance_output_${op}_container`;
}
/** Convenience: resolve both codes for a loaded contract. */
export function contractClearanceCodes(contract: Contract): {
inputCode: string | null;
outputCode: string | null;
includesCustoms: boolean;
} {
const includesCustoms = contract.customsClearingEnabled ?? false;
return {
inputCode: contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
includesCustoms,
),
outputCode: contractClearanceOutputSettingCode(
contract.tradeDirection,
contract.freightType,
includesCustoms,
),
includesCustoms,
};
}

View File

@@ -0,0 +1,235 @@
import { Injectable } from '@nestjs/common';
import { RatesService } from '../rule-engine/services/rates.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { ExchangeService } from '@edr/api-common';
import { ContractsRepository } from './contracts.repository';
import { Contract } from './entities/contract.entity';
/** A single unit-rate line at contract phase — NO quantities, NO totals. */
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat';
unitPrice: number;
containerSize?: string | null;
conditionalOn?: string | null;
cargoTypeCode?: string | null;
}
/** The contract `pricing_breakdown` shape (doc §9.1). */
export interface ContractPricingBreakdown {
displayMode: 'UNIT_RATES';
currency: string;
lineItems: ContractUnitRateLineItem[];
generatedAt: string;
}
/** Map a rate's storage unit to the contract-display unit. */
function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
switch (rateUnit) {
case 'PER_TON':
return 'per_ton';
case 'PER_KM':
return 'per_km';
case 'PER_CONTAINER':
case 'PER_WAGON':
return 'per_container';
default:
return 'flat';
}
}
@Injectable()
export class ContractPricingService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly ratesService: RatesService,
private readonly containerTypesService: ContainerTypesService,
private readonly exchangeService: ExchangeService,
) {}
/** Base rail rate type for the contract's direction + freight. */
private baseRateType(contract: Contract): string {
const isBulk = contract.freightType === 'BULK';
if (contract.tradeDirection === 'IMPORT') {
return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT';
}
if (contract.tradeDirection === 'EXPORT') {
return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT';
}
return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER';
}
/**
* Build the unit-rate breakdown from live rates. Emits per-unit prices only
* (one per container size, conditional hazard/reefer surcharges, and bulk
* commodity rate) — NO totals or quantities (doc §9.1).
*/
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
const liveRates = await this.ratesService.findLiveRates();
const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);
const lineItems: ContractUnitRateLineItem[] = [];
const baseType = this.baseRateType(contract);
if (contract.freightType === 'CONTAINER') {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { data: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 500,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt);
const matchedIds = new Set(matchedTypes.map((ct) => ct.id));
const rate =
liveRates.find(
(r) =>
r.rateType === baseType &&
r.currency === 'USD' &&
r.containerTypeId &&
matchedIds.has(r.containerTypeId),
) ??
liveRates.find(
(r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId,
);
if (!rate) continue;
lineItems.push({
code: `CONTAINER_${size.toUpperCase()}`,
label: `${size} container`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
});
}
} else {
const bulkRate =
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
if (bulkRate) {
lineItems.push({
code: 'BULK_FREIGHT',
label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo',
unit: toContractUnit(bulkRate.rateUnit),
unitPrice: convert(Number(bulkRate.rateValue)),
cargoTypeCode: cargoScope?.cargoType?.code ?? null,
});
}
}
// First / last mile trucking unit rates — shown when the contract carries
// that leg. Per-unit prices only; the actual amount (× km / containers /
// tons / flat) is computed at booking time.
if (contract.firstMilePickupAddress) {
const fm = liveRates.find(
(r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD',
);
if (fm && Number(fm.rateValue) > 0) {
lineItems.push({
code: 'FIRST_MILE',
label: 'First mile (pick-up)',
unit: toContractUnit(fm.rateUnit),
unitPrice: convert(Number(fm.rateValue)),
});
}
}
if (contract.lastMileDeliveryAddress) {
const lm = liveRates.find(
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
);
if (lm && Number(lm.rateValue) > 0) {
lineItems.push({
code: 'LAST_MILE',
label: 'Last mile (delivery)',
unit: toContractUnit(lm.rateUnit),
unitPrice: convert(Number(lm.rateValue)),
});
}
}
// Conditional surcharges — shown only when the contract toggles them on AND
// the rate has a non-zero value (a 0 rate means "no surcharge").
if (contract.isHazardous) {
const hazard = liveRates.find(
(r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD',
);
if (hazard && Number(hazard.rateValue) > 0) {
lineItems.push({
code: 'HAZARD_SURCHARGE',
label: 'Hazardous surcharge',
unit: toContractUnit(hazard.rateUnit),
unitPrice: convert(Number(hazard.rateValue)),
conditionalOn: 'is_hazardous',
});
}
}
if (contract.isReefer) {
const reefer = liveRates.find(
(r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD',
);
if (reefer && Number(reefer.rateValue) > 0) {
lineItems.push({
code: 'REEFER_SURCHARGE',
label: 'Reefer surcharge',
unit: toContractUnit(reefer.rateUnit),
unitPrice: convert(Number(reefer.rateValue)),
conditionalOn: 'is_reefer',
});
}
}
return {
displayMode: 'UNIT_RATES',
currency,
lineItems,
generatedAt: new Date().toISOString(),
};
}
/** Generate (and persist) the unit-rate breakdown for a contract. */
async generatePrice(contractId: string): Promise<ContractPricingBreakdown> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) {
throw new Error(`Contract ${contractId} not found`);
}
const breakdown = await this.buildBreakdown(contract);
await this.contractsRepository.update(contractId, {
pricingBreakdown: breakdown as never,
pricingDisplayMode: 'UNIT_RATES',
} as never);
return breakdown;
}
/**
* Freeze the contract's unit rates into contract_rate_snapshots (one row per
* rate line) at submit time. The booking later computes totals from these.
*/
async freezeRateSnapshots(contractId: string): Promise<void> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) return;
const breakdown =
(contract.pricingBreakdown as ContractPricingBreakdown | null) ??
(await this.buildBreakdown(contract));
await this.contractsRepository.clearRateSnapshots(contractId);
for (const line of breakdown.lineItems) {
await this.contractsRepository.createRateSnapshot({
contractId,
rateCode: line.code,
description: line.label,
unitPrice: line.unitPrice,
unitOfMeasure: line.unit,
currency: breakdown.currency,
containerSize: line.containerSize ?? null,
isSurcharge: !!line.conditionalOn,
conditionalOn: line.conditionalOn ?? null,
});
}
}
}

View File

@@ -0,0 +1,642 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
} from '@nestjs/common';
import { Readable } from 'stream';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService } from './contracts.service';
import { contractClearanceSettingCode } from './contract-clearance.util';
import { Contract } from './entities/contract.entity';
import { ContractSignerRole } from './entities/contract-signature.entity';
import { SignContractDto } from './dto/sign-contract.dto';
/**
* Dropdown-settings code holding the admin-configured contract validity options
* (each option's `value` is a day count). The staff accept dialog reads the same
* code, so accept can only use a configured duration. See the seed migration
* `SeedContractValidityPeriods`.
*/
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
/** Status-machine guard mirroring booking-status.util. */
function assertContractStatus(contract: Contract, allowed: string[]): void {
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`,
);
}
}
@Injectable()
export class ContractTransitionService {
private readonly logger = new Logger(ContractTransitionService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
private readonly approvalRulesService: ApprovalRulesService,
private readonly cargoTypesService: CargoTypesService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly filesService: FilesService,
private readonly signaturesService: SignaturesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
async submit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']);
await this.pricingService.generatePrice(contractId);
await this.pricingService.freezeRateSnapshots(contractId);
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
}
/** Confirm a price change before submit (mirrors booking confirm-submit). */
async confirmSubmit(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']);
await this.pricingService.generatePrice(contractId);
await this.pricingService.freezeRateSnapshots(contractId);
await this.contractsRepository.update(contractId, {
status: 'SUBMITTED',
} as never);
return this.contractsService.findById(contractId);
}
/**
* Line staff accepts intake: set the validity window from validityDays and
* instantiate the approval steps from approval_rules → PENDING_APPROVAL.
*/
async staffAccept(
contractId: string,
actorId: string,
validityDays: number,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) {
throw new BadRequestException(
'A contract validity (in days) is required to accept this contract.',
);
}
await this.assertValidityDaysConfigured(validityDays);
const validFrom = new Date();
const validUntil = new Date(validFrom);
validUntil.setDate(validUntil.getDate() + validityDays);
await this.instantiateApprovalSteps(contract);
await this.contractsRepository.update(contractId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: validFrom,
contractValidityDays: validityDays,
contractValidFrom: validFrom,
contractValidUntil: validUntil,
} as never);
return this.contractsService.findById(contractId);
}
/**
* Ensure the chosen validity (days) is one of the admin-configured options in
* the `contract_validity_periods` dropdown setting. If the setting is missing
* or has no options yet, fall back to the DTO range check (already applied) so
* acceptance is never hard-blocked before an admin configures the list.
*/
private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
let setting;
try {
setting = await this.dropdownSettingsService.getByCode(
CONTRACT_VALIDITY_PERIODS_CODE,
);
} catch {
// Not configured yet — keep the flow working with the DTO range only.
return;
}
const allowed = (setting.children ?? [])
.map((o) => Number(o.value))
.filter((n) => Number.isFinite(n));
if (allowed.length === 0) return;
if (!allowed.includes(validityDays)) {
throw new BadRequestException(
`Validity ${validityDays} days is not a configured option. Allowed: ${allowed
.sort((a, b) => a - b)
.join(', ')} days.`,
);
}
}
/**
* Build contract approval steps from the system approval_rules chain (US-06:
* container → line staff + director; bulk → directors + CEO). Mirrors the
* booking transition's instantiateApprovalSteps but writes contract steps.
*/
private async instantiateApprovalSteps(contract: Contract): Promise<void> {
if ((contract.approvalSteps?.length ?? 0) > 0) return;
const cargoTypeId =
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
// US-06 routing: bulk always needs director approval; container needs it only
// when its cargo type flags it. Resolve the chain via the same approval_rules
// source of truth the booking flow uses (no booking row is created here).
let requiresDirectorApproval = contract.freightType === 'BULK';
if (cargoTypeId) {
const cargoType = await this.cargoTypesService.findById(cargoTypeId);
if (cargoType?.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
for (const rule of chain) {
await this.contractsRepository.createApprovalStep({
contractId: contract.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
}
}
async requestChanges(
contractId: string,
note: string,
actorId: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED']);
await this.contractsRepository.createReviewNote(
contractId,
note,
'CHANGES_REQUESTED',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.contractsService.findById(contractId);
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote(
contractId,
reason,
'REJECTION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'REJECTED',
} as never);
return this.contractsService.findById(contractId);
}
/** Approve one approval step in sequence; → APPROVED when all complete. */
async approveStep(
contractId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Contract> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.contractsRepository.findApprovalStepById(contractId, stepId);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
}
const next = await this.contractsRepository.findNextPendingApprovalStep(contractId);
if (!next || next.id !== step.id) {
throw new BadRequestException('Approval steps must be completed in order');
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
if (step.blocksRole && step.blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
// Record who acted on this step, but DO NOT advance the contract status here —
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
// every step in the chain is complete; until then the contract stays in
// PENDING_APPROVAL so the next required role can act.
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';
if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never);
}
return this.contractsService.findById(contractId);
}
/**
* Render the contract PDF from the Contract aggregate, store it via FilesService,
* stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/
* Chromium) is best-effort and must NOT block the contract from becoming ready —
* the document is (re)rendered lazily on view/download once Chromium is available.
*/
async generateContract(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']);
const { view } = await this.documentViewModelBuilder.build(contractId);
try {
await this.upsertContractPdf(contractId, contract.reference, view);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
await this.contractsRepository.update(contractId, {
status: 'CONTRACT_READY',
contractTemplateKey: view.templateKey,
contractGeneratedAt: new Date(),
} as never);
return this.contractsService.findById(contractId);
}
/**
* Build the contract PDF view-model and rendered HTML for portal/backoffice
* signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no
* totals). Returns the view-model, the rendered HTML and the signature rows.
*/
async getContractDocumentView(contractId: string): Promise<{
view: ContractViewModel;
html: string;
signatures: ContractViewModel['signatures'];
}> {
const { view } = await this.documentViewModelBuilder.build(contractId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return { view, html, signatures: view.signatures };
}
/**
* Rebuild the stored `contract` PDF from the current aggregate (now including
* the latest signatures) so the downloaded/viewed file matches the live HTML
* view. Best-effort — a Chromium hiccup must never fail the signing
* transaction; the doc still re-renders live on /contract/view.
*/
private async regenerateContractPdf(
contractId: string,
reference: string,
): Promise<void> {
try {
const { view } = await this.documentViewModelBuilder.build(contractId);
await this.upsertContractPdf(contractId, reference, view);
} catch (err) {
this.logger.warn(
`Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`,
);
}
}
/** Render the contract PDF and upsert it as the `contract` file on the contract. */
private async upsertContractPdf(
contractId: string,
reference: string,
view: ContractViewModel,
): Promise<FileRecord> {
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'contract',
file,
});
}
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
/* keep original url */
}
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
/** Apply a digital signature row (mirrors booking-contract.service). */
private async applySignature(
contract: Contract,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<void> {
const role = dto.role as ContractSignerRole;
// Resolve the signature image. The client may send a freshly-drawn image, or
// omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.
let imageBase64 = dto.signatureImageBase64;
let signerDisplayName = dto.signerDisplayName;
if (!imageBase64 && options.signerUserId) {
const saved = await this.signaturesService.getForUser(options.signerUserId);
if (saved?.signatureImageUrl) {
imageBase64 = saved.signatureImageUrl;
signerDisplayName = signerDisplayName || saved.signerDisplayName;
}
}
if (!imageBase64) {
throw new BadRequestException(
'No signature provided and no saved signature found on the profile.',
);
}
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code: `signature_${role.toLowerCase()}`,
file: sigFile,
});
await this.contractsRepository.saveSignature({
contractId: contract.id,
role,
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null,
});
// Only (re)save the reusable profile signature when the signer drew a NEW
// image. Reusing the saved signature must not rewrite it with itself.
if (options.signerUserId && dto.signatureImageBase64) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
);
}
}
}
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
async sign(
contractId: string,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
if (dto.role === 'CUSTOMER') {
assertContractStatus(contract, ['CONTRACT_READY']);
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
}
return this.counterSign(contractId, dto, options);
}
/**
* Staff/Director/CEO counter-sign → branch on the execution path. A customs
* border (IMPORT/EXPORT) always requires a clearance gate before any shipment;
* who reviews differs:
* - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL
* uploads output, then GL creates the booking.
* - Path A (no customs): the customer clears the cargo himself and uploads his
* own clearance proof; Operations reviews it; then the CUSTOMER books.
* DOMESTIC/intercity has no border, so it goes straight to executed.
*/
async counterSign(
contractId: string,
dto: SignContractDto,
options: { signerUserId?: string },
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
await this.applySignature(contract, dto, options);
const now = new Date();
const updates: Record<string, unknown> = {
fullyExecutedAt: now,
lockedAt: now,
};
// A clearance gate applies whenever a clearance doc set resolves — Path B
// (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC
// resolves to null on both paths and skips straight to executed.
const clearanceCode = contractClearanceSettingCode(
contract.tradeDirection,
contract.freightType,
contract.customsClearingEnabled ?? false,
);
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
// level: there is no contract clearance cycle. The contract just becomes
// active; the customer then files shipment requests and GL books + clears
// each one. ONE_TIME customs and Path A self-clearance keep the contract
// cycle below.
const isGeneralCustoms =
contract.contractKind === 'GENERAL' &&
Boolean(contract.customsClearingEnabled);
if (clearanceCode && !isGeneralCustoms) {
// Open a clearance cycle, seed the pre-booking milestones, and route the
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
// distinction is enforced at the review/finalize endpoints, not here.
const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1;
const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber);
await this.milestoneService.seedPreBookingMilestones(contract, cycle.id);
updates.status = 'AWAITING_CLEARANCE_DOCUMENTS';
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
updates.clearanceCycleNumber = cycleNumber;
} else {
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
// clears per booking). Ready for shipment requests / direct booking.
updates.status =
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
updates.clearanceStatus = 'NOT_APPLICABLE';
}
await this.contractsRepository.update(contractId, updates as never);
await this.regenerateContractPdf(contractId, contract.reference);
return this.contractsService.findById(contractId);
}
/** Customer requests renewal → RENEWAL_DRAFT linked via renewalOfId. */
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);
const reference = await this.generateRenewalReference();
const renewal = await this.contractsRepository.create({
reference,
companyId: source.companyId,
companyProfileId: source.companyProfileId,
isGovernment: source.isGovernment,
governmentInstitution: source.governmentInstitution,
contractKind: source.contractKind,
renewalOfId: source.id,
tradeDirection: source.tradeDirection,
freightType: source.freightType,
serviceTypeId: source.serviceTypeId,
paymentCurrency: source.paymentCurrency,
customsClearingEnabled: source.customsClearingEnabled,
customsClearingAgent: source.customsClearingAgent,
equipmentReturn: source.equipmentReturn,
firstMilePickupAddress: source.firstMilePickupAddress,
firstMilePickupLat: source.firstMilePickupLat,
firstMilePickupLng: source.firstMilePickupLng,
lastMileDeliveryAddress: source.lastMileDeliveryAddress,
lastMileDeliveryLat: source.lastMileDeliveryLat,
lastMileDeliveryLng: source.lastMileDeliveryLng,
isHazardous: source.isHazardous,
isReefer: source.isReefer,
contractType: source.contractType,
versionNumber: (source.versionNumber ?? 1) + 1,
status: 'RENEWAL_DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
void userId;
return this.contractsService.findById(renewal.id);
}
private async generateRenewalReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
}
}

View File

@@ -0,0 +1,729 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UnauthorizedException,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
assertFreightPermission,
hasFreightPermission,
} from '../../common/freight-permission.util';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { ContractsService } from './contracts.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { BookingRequestService } from './booking-request.service';
import { SignaturesService } from '../signatures/signatures.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { AcceptContractDto } from './dto/accept-contract.dto';
import {
ApproveStepDto,
RejectContractDto,
RequestChangesDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CreateBookingRequestDto,
ReviewBookingRequestDto,
} from './dto/create-booking-request.dto';
import {
AdviseDutyDto,
AssignRiskDto,
AssignStationDto,
CompleteMilestoneDto,
ReportIncidentDto,
} from './dto/gl-operations.dto';
@ApiTags('contracts')
@Controller('contracts')
@ApiBearerAuth()
export class ContractsController {
constructor(
private readonly contractsService: ContractsService,
private readonly pricingService: ContractPricingService,
private readonly transitionService: ContractTransitionService,
private readonly clearanceService: ContractClearanceService,
private readonly contractBookingService: ContractBookingService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly glOperationsService: GlOperationsService,
private readonly bookingRequestService: BookingRequestService,
private readonly signaturesService: SignaturesService,
) {}
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
// STATIC routes declared before any `:id`-param route so Nest matches them
// (mirrors the clearance/queue ordering below).
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
}
@Get('booking-requests/:reqId')
@ApiOperation({ summary: 'A single shipment request' })
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
return this.bookingRequestService.findOne(reqId);
}
@Post('booking-requests/:reqId/accept')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' })
acceptBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string,
@Body() body: { bookingId: string },
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingRequestService.accept(
reqId,
body.bookingId,
resolveAuthUserId(user),
);
}
@Post('booking-requests/:reqId/reject')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL rejects a shipment request' })
rejectBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string,
@Body() dto: ReviewBookingRequestDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user));
}
@Post('booking-requests/:reqId/cancel')
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
cancelBookingRequest(
@Param('reqId', ParseUUIDPipe) reqId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user));
}
@Post(':id/booking-requests')
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
submitBookingRequest(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingRequestDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user));
}
@Get(':id/booking-requests')
@ApiOperation({ summary: 'List the shipment requests on a contract' })
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingRequestService.listForContract(id);
}
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' })
@ApiBody({ type: CreateContractDto })
async create(
@Body() dto: CreateContractDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
}
return this.contractsService.create(dto, files ?? [], user?.id);
}
@Get()
@ApiOperation({ summary: 'List contracts (paginated)' })
async findAll(
@Query() filter: FilterContractDto,
@CurrentUser() user: TCurrentUser,
) {
// Staff see every contract; customers are force-scoped to their own company.
if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
return this.contractsService.findAll(filter);
}
const userId = user?.id;
if (!userId) throw new UnauthorizedException('Authentication required');
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
return this.contractsService.findAll(filter, companyId);
}
@Get('my')
@ApiOperation({ summary: "List the current customer's contracts" })
async findMy(
@CurrentUser() user: AuthUserPayload,
@Query() filter: FilterContractDto,
) {
const userId = resolveAuthUserId(user);
const companyId = await this.contractsService.resolveCustomerCompanyId(userId);
if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
return this.contractsService.findAll(filter, companyId);
}
@Get('list-summary')
@ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' })
@ApiOkResponse({ type: ContractListSummaryDto })
findListSummary(@Query() filter: FilterContractDto) {
return this.contractsService.getListSummary(filter);
}
@Get('clearance/queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
clearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.queue(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) &&
!hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return contract;
}
@Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'Update contract',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateContractDto })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateContractDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.contractsService.update(id, dto, files ?? []);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Soft-delete DRAFT contract' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.contractsService.remove(id);
}
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' })
uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.contractsService.uploadDocuments(id, files ?? []);
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@ApiOperation({ summary: 'Confirm submit after a price change' })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
staffAccept(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.staffAccept(
id,
resolveAuthUserId(user),
dto.validityDays,
);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@ApiOperation({ summary: 'Staff return contract for customer updates' })
requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.contracts.reject)
@ApiOperation({ summary: 'Staff reject contract' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.approveDirector,
FREIGHT_PERMS.contracts.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.contracts.generateContract)
@ApiOperation({ summary: 'Generate contract document → CONTRACT_READY' })
generateContract(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.generateContract(id);
}
@Get(':id/contract/view')
@ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' })
async getContractView(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
const { view, html, signatures } =
await this.transitionService.getContractDocumentView(id);
// The signer's reusable saved signature (if any) so the sign UI can offer
// "Approve & sign" with the stored image instead of forcing a fresh draw.
const signerId = resolveAuthUserId(user);
const savedSignature = signerId
? await this.signaturesService.getForUser(signerId)
: null;
return {
contractId: view.bookingId,
reference: view.reference,
status: view.status,
templateKey: view.templateKey,
title: view.template.title,
html,
view,
canSignCustomer: view.canSignCustomer,
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures,
savedSignature,
};
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.sign(id, dto, {
signerUserId: user?.id ?? user?.sub,
});
}
@Post(':id/renew')
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
renew(
@Param('id', ParseUUIDPipe) id: string,
@Body() _dto: RenewContractDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.transitionService.renew(id, user?.id ?? user?.sub);
}
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
@Get(':id/clearance')
@ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' })
getClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.getClearanceView(id);
}
@Post(':id/clearance/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
uploadClearanceDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.clearanceService.uploadDocuments(id, files ?? []);
}
@Post(':id/clearance/review')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewClearanceDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.reviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/clearance/output-documents')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' })
uploadOutputDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.clearanceService.uploadOutputDocuments(id, files ?? []);
}
@Post(':id/clearance/finalize')
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' })
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.finalize(id);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review',
})
opsClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.opsQueue(filter);
}
@Post(':id/clearance/ops-review')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations reviews a customer self-clearance document (Approve | Query)',
})
opsReviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReviewClearanceDocumentDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.opsReviewDocument(
id,
dto.fileKey,
dto.status,
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/clearance/ops-finalize')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({
summary: 'Operations finalizes self-clearance → customer may create the booking',
})
opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.opsFinalize(id);
}
@Get('clearance/history')
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
@ApiOperation({ summary: 'GL ET clearance history: contracts that completed Path B clearance' })
clearanceHistory(@Query() filter: FilterContractDto) {
return this.clearanceService.history(filter);
}
@Get('clearance/ops-history')
@BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview)
@ApiOperation({ summary: 'Operations clearance history: contracts that completed Path A self-clearance' })
opsClearanceHistory(@Query() filter: FilterContractDto) {
return this.clearanceService.opsHistory(filter);
}
// ── Booking under contract (Path A customer / Path B GL ET) ────────────────
@Post(':id/bookings')
@ApiOperation({
summary:
'Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia).',
})
createBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
@CurrentUser() user: AuthUserPayload,
) {
// The service decides the execution path from the contract:
// Path A (customs disabled) → customer/staff create; status checks apply.
// Path B (customs enabled) → GL Ethiopia only, once clearance is ready.
return this.contractBookingService.createUnderContract(
id,
dto,
{ id: user?.id ?? user?.sub },
user,
);
}
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
})
async capacity(@Param('id', ParseUUIDPipe) id: string) {
const contract = await this.contractsService.findById(id);
return this.contractBookingService.computeCapacity(contract);
}
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
@Get(':id/milestones')
@ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' })
listContractMilestones(@Param('id', ParseUUIDPipe) id: string) {
return this.milestoneService.listForContract(id);
}
@Get('bookings/:bookingId/milestones')
@ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' })
listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.milestoneService.listForBooking(bookingId);
}
@Post('bookings/:bookingId/milestones/:code/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' })
completeBookingMilestone(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Param('code') code: string,
@Body() body: { note?: string },
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.completeForBooking(
bookingId,
code,
user?.id ?? user?.sub,
body?.note,
);
}
@Post(':id/milestones/:code/complete')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' })
completeContractMilestone(
@Param('id', ParseUUIDPipe) id: string,
@Param('code') code: string,
@Body() body: CompleteMilestoneDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.completeForContract(
id,
code,
resolveAuthUserId(user),
body?.note,
);
}
// ── GL operational actions on a booking (doc §11§13) ──────────────────────
@Post('bookings/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/duty')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' })
adviseDuty(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AdviseDutyDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.adviseDuty(
bookingId,
{ amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial },
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/station-assign')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' })
assignStation(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignStationDto,
) {
return this.glOperationsService.assignStation(bookingId, {
stationYardId: dto.stationYardId,
staffId: dto.staffId,
});
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
})
uploadGlDocuments(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
}
@Post('bookings/:bookingId/duty-slip')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
uploadDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
}
@Get('bookings/:bookingId/incidents')
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId);
}
@Post('bookings/:bookingId/incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' })
reportIncident(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ReportIncidentDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.reportIncident(
bookingId,
{
incidentType: dto.incidentType,
description: dto.description,
files: files ?? [],
},
resolveAuthUserId(user),
);
}
}

View File

@@ -0,0 +1,110 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingsModule } from '../bookings/bookings.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service';
import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { BookingRequestService } from './booking-request.service';
import { BookingRequestRepository } from './booking-request.repository';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractSignature } from './entities/contract-signature.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { ContractReviewNote } from './entities/contract-review-note.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ContractDocumentReview } from './entities/contract-document-review.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ClearanceIncident } from './entities/clearance-incident.entity';
import { BookingRequest } from './entities/booking-request.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@Module({
imports: [
TypeOrmModule.forFeature([
Contract,
ContractRoute,
ContractCargoScope,
ContractRateSnapshot,
ContractSignature,
ContractApprovalStep,
ContractReviewNote,
ContractClearanceCycle,
ContractDocumentReview,
ClearanceMilestone,
ClearanceIncident,
BookingRequest,
Booking,
BookingContainerUnit,
]),
RuleEngineModule,
FileUploadSettingsModule,
DropdownSettingsModule,
FilesModule,
MinioModule,
SignaturesModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
BookingsModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [ContractsController],
providers: [
ContractsService,
ContractsRepository,
ContractPricingService,
ContractTransitionService,
ContractClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
BookingRequestService,
BookingRequestRepository,
// Contract PDF providers (template resolution + render + PDF) — stateless
// helpers reused from src/contracts/.
ContractTemplateResolver,
ContractRendererService,
ContractPdfService,
ContractDocumentViewModelBuilder,
],
exports: [
ContractsService,
ContractsRepository,
ContractPricingService,
ContractTransitionService,
ContractClearanceService,
ContractBookingService,
ClearanceMilestoneService,
],
})
export class ContractsModule {}

View File

@@ -0,0 +1,555 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
import { ContractApprovalStep } from './entities/contract-approval-step.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import {
ContractDocReviewStatus,
ContractDocumentReview,
} from './entities/contract-document-review.entity';
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
export interface ContractListFilterOptions {
statuses?: string[];
status?: string;
companyId?: string;
companyProfileId?: string;
contractKind?: string;
serviceTypeId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
customsClearingEnabled?: boolean;
createdFrom?: string;
createdTo?: string;
}
@Injectable()
export class ContractsRepository extends BaseRepository<Contract> {
constructor(
@InjectRepository(Contract)
repository: Repository<Contract>,
private readonly dataSource: DataSource,
) {
super(repository);
}
/** Find a contract by its human-readable reference number. */
findByReference(reference: string): Promise<Contract | null> {
return this.repository.findOne({ where: { reference } });
}
/** Count contracts created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
.createQueryBuilder('contract')
.where('contract.created_at >= :startDate', { startDate })
.andWhere('contract.created_at < :endDate', { endDate })
.getCount();
}
/** Find a contract by ID with all child collections, service type, company and files. */
async findByIdWithRelations(id: string): Promise<Contract | null> {
if (!id) return null;
const contract = await this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
.leftJoinAndSelect('contract.signatures', 'signatures')
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
.leftJoinAndSelect('contract.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('contract.serviceType', 'serviceType')
.leftJoinAndSelect('contract.company', 'company')
.where('contract.id = :id', { id })
.leftJoinAndMapMany(
'contract.files',
FileRecord,
'file',
"file.resource_id = contract.id AND file.resource = 'contracts'",
)
.getOne();
return contract ?? null;
}
/** Paginated list with optional multi-status filter (API tab queues). */
async findAllPaginated(
options: ContractListFilterOptions & {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
},
): Promise<{
items: Contract[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = options.page;
const pageSize = options.pageSize;
const qb = this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.company', 'company')
.leftJoinAndSelect('contract.serviceType', 'serviceType')
.leftJoinAndSelect('contract.routes', 'routes')
.leftJoinAndSelect('routes.originYard', 'routeOrigin')
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
.where('contract.deleted_at IS NULL');
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'contractValidUntil'
? 'contract.contractValidUntil'
: 'contract.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
// Attach the generated contract PDF to each row so list/home can offer a
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
items,
total,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
/**
* Load contract-resource files for the given contracts and attach them to
* `contract.files`. Kept separate from the paginated query so the one-to-many
* join doesn't inflate the page count.
*/
private async attachContractFiles(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const files = await this.dataSource.getRepository(FileRecord).find({
where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() },
});
const byContract = new Map<string, FileRecord[]>();
for (const file of files) {
const list = byContract.get(file.resourceId) ?? [];
list.push(file);
byContract.set(file.resourceId, list);
}
for (const contract of contracts) {
contract.files = byContract.get(contract.id) ?? [];
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')
.select('contract.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('contract.deleted_at IS NULL')
.groupBy('contract.status')
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
async getListSummaryMetrics(
options: ContractListFilterOptions & {
page: number;
pageSize: number;
needsActionStatuses: readonly string[];
},
): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> {
const baseQb = () => {
const qb = this.repository
.createQueryBuilder('contract')
.where('contract.deleted_at IS NULL');
this.applyListFilters(qb, options);
return qb;
};
const inQueue = await baseQb().getCount();
const needsAction = await baseQb()
.andWhere('contract.status IN (:...needsActionStatuses)', {
needsActionStatuses: [...options.needsActionStatuses],
})
.getCount();
const offset = (options.page - 1) * options.pageSize;
const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset));
return { inQueue, onThisPage, needsAction };
}
private applyListFilters(
qb: SelectQueryBuilder<Contract>,
options: ContractListFilterOptions,
): void {
if (options.statuses?.length) {
qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses });
} else if (options.status) {
qb.andWhere('contract.status = :status', { status: options.status });
}
if (options.companyId) {
qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId });
}
if (options.companyProfileId) {
qb.andWhere('contract.company_profile_id = :companyProfileId', {
companyProfileId: options.companyProfileId,
});
}
if (options.contractKind) {
qb.andWhere('contract.contract_kind = :contractKind', {
contractKind: options.contractKind,
});
}
if (options.customsClearingEnabled !== undefined) {
qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', {
customsClearingEnabled: options.customsClearingEnabled,
});
}
if (options.serviceTypeId) {
qb.andWhere('contract.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,
});
}
if (options.freightType) {
qb.andWhere('contract.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
qb.andWhere('contract.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.paymentCurrency) {
qb.andWhere('contract.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
}
if (options.createdFrom) {
qb.andWhere('contract.created_at >= :createdFrom', {
createdFrom: options.createdFrom,
});
}
if (options.createdTo) {
qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo });
}
}
// ── Approval steps ─────────────────────────────────────────────────────────
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
contractId: string,
): Promise<ContractApprovalStep | null> {
return this.dataSource.getRepository(ContractApprovalStep).findOne({
where: { contractId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
contractId: string,
stepId: string,
): Promise<ContractApprovalStep | null> {
return this.dataSource.getRepository(ContractApprovalStep).findOne({
where: { contractId, id: stepId },
});
}
/** Mark an approval step complete. */
async completeApprovalStep(
stepId: string,
actorId: string,
status: 'APPROVED' | 'REJECTED',
note?: string,
): Promise<void> {
await this.dataSource.getRepository(ContractApprovalStep).update(stepId, {
status,
actedByStaffId: actorId,
actedAt: new Date(),
note,
});
}
/** Check if all approval steps are approved. */
async allApprovalStepsComplete(contractId: string): Promise<boolean> {
const pending = await this.dataSource.getRepository(ContractApprovalStep).count({
where: { contractId, status: 'PENDING' },
});
return pending === 0;
}
/** Persist a contract approval step (instantiated at staff accept). */
async createApprovalStep(
data: Partial<ContractApprovalStep>,
): Promise<ContractApprovalStep> {
const repo = this.dataSource.getRepository(ContractApprovalStep);
return repo.save(repo.create(data));
}
// ── Signatures ──────────────────────────────────────────────────────────────
findSignatures(contractId: string): Promise<ContractSignature[]> {
return this.dataSource.getRepository(ContractSignature).find({
where: { contractId },
relations: ['signatureFile'],
order: { signedAt: 'ASC' },
});
}
findSignature(
contractId: string,
role: ContractSignerRole,
): Promise<ContractSignature | null> {
return this.dataSource.getRepository(ContractSignature).findOne({
where: { contractId, role },
relations: ['signatureFile'],
});
}
async saveSignature(data: Partial<ContractSignature>): Promise<ContractSignature> {
const repo = this.dataSource.getRepository(ContractSignature);
const existing = await repo.findOne({
where: { contractId: data.contractId!, role: data.role! },
});
if (existing) {
Object.assign(existing, data);
return repo.save(existing);
}
return repo.save(repo.create(data));
}
// ── Review notes ──────────────────────────────────────────────────────────────
async createReviewNote(
contractId: string,
body: string,
noteType: ContractReviewNoteType,
authorUserId?: string,
authorRole?: string,
): Promise<ContractReviewNote> {
const repo = this.dataSource.getRepository(ContractReviewNote);
return repo.save(
repo.create({
contractId,
body,
noteType,
authorUserId: authorUserId ?? null,
authorRole: authorRole ?? null,
}),
);
}
async findLatestReviewNote(
contractId: string,
noteType?: ContractReviewNoteType,
): Promise<ContractReviewNote | null> {
const repo = this.dataSource.getRepository(ContractReviewNote);
return repo.findOne({
where: noteType ? { contractId, noteType } : { contractId },
order: { createdAt: 'DESC' },
});
}
// ── Pre-booking clearance document reviews ────────────────────────────────────
findDocumentReviews(
contractId: string,
cycleId?: string | null,
): Promise<ContractDocumentReview[]> {
return this.dataSource.getRepository(ContractDocumentReview).find({
where:
cycleId !== undefined
? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId }
: { contractId },
order: { createdAt: 'ASC' },
});
}
/**
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed
* on (contractId, clearanceCycleId, settingCode, fileKey).
*/
async upsertDocumentReviewPending(input: {
contractId: string;
clearanceCycleId?: string | null;
settingCode: string;
fileKey: string;
fileRecordId: string;
uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ';
}): Promise<void> {
const repo = this.dataSource.getRepository(ContractDocumentReview);
const cycleId = input.clearanceCycleId ?? null;
const existing = await repo.findOne({
where: {
contractId: input.contractId,
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
settingCode: input.settingCode,
fileKey: input.fileKey,
},
});
if (existing) {
await repo.update(existing.id, {
fileRecordId: input.fileRecordId,
status: 'PENDING',
note: null,
reviewedByStaffId: null,
reviewedAt: null,
});
return;
}
await repo.save(
repo.create({
contractId: input.contractId,
clearanceCycleId: cycleId,
settingCode: input.settingCode,
fileKey: input.fileKey,
fileRecordId: input.fileRecordId,
status: 'PENDING',
uploadedByRole: input.uploadedByRole ?? 'CUSTOMER',
}),
);
}
/** GL marks a document APPROVED or QUERIED (with an optional note). */
async setDocumentReviewStatus(input: {
contractId: string;
clearanceCycleId?: string | null;
settingCode: string;
fileKey: string;
status: ContractDocReviewStatus;
staffId: string;
note?: string;
}): Promise<void> {
const repo = this.dataSource.getRepository(ContractDocumentReview);
const cycleId = input.clearanceCycleId ?? null;
const existing = await repo.findOne({
where: {
contractId: input.contractId,
clearanceCycleId: cycleId === null ? IsNull() : cycleId,
settingCode: input.settingCode,
fileKey: input.fileKey,
},
});
const patch = {
status: input.status,
note: input.note ?? null,
reviewedByStaffId: input.staffId,
reviewedAt: new Date(),
};
if (existing) {
await repo.update(existing.id, patch);
return;
}
await repo.save(
repo.create({
contractId: input.contractId,
clearanceCycleId: cycleId,
settingCode: input.settingCode,
fileKey: input.fileKey,
...patch,
}),
);
}
// ── Clearance cycles ──────────────────────────────────────────────────────────
/** The current (latest, non-completed) clearance cycle for a contract. */
async currentCycle(contractId: string): Promise<ContractClearanceCycle | null> {
return this.dataSource.getRepository(ContractClearanceCycle).findOne({
where: { contractId },
order: { cycleNumber: 'DESC' },
});
}
/** Open a new clearance cycle (incrementing cycle_number). */
async openCycle(
contractId: string,
cycleNumber: number,
): Promise<ContractClearanceCycle> {
const repo = this.dataSource.getRepository(ContractClearanceCycle);
return repo.save(
repo.create({
contractId,
cycleNumber,
status: 'AWAITING_DOCUMENTS',
}),
);
}
async setCycleStatus(
cycleId: string,
status: string,
fields: Partial<
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'>
> = {},
): Promise<void> {
await this.dataSource
.getRepository(ContractClearanceCycle)
.update(cycleId, { status, ...fields } as never);
}
/** Link the GL-created booking to a clearance cycle. */
async linkBooking(cycleId: string, bookingId: string): Promise<void> {
await this.dataSource
.getRepository(ContractClearanceCycle)
.update(cycleId, { bookingId });
}
// ── Rate snapshots ──────────────────────────────────────────────────────────
async createRateSnapshot(
data: Partial<ContractRateSnapshot>,
): Promise<ContractRateSnapshot> {
const repo = this.dataSource.getRepository(ContractRateSnapshot);
return repo.save(repo.create(data));
}
async clearRateSnapshots(contractId: string): Promise<void> {
await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId });
}
findRateSnapshots(contractId: string): Promise<ContractRateSnapshot[]> {
return this.dataSource.getRepository(ContractRateSnapshot).find({
where: { contractId },
order: { createdAt: 'ASC' },
});
}
}

View File

@@ -0,0 +1,532 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto';
import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedContracts {
items: Contract[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
'SIGNED_CUSTOMER',
] as const;
@Injectable()
export class ContractsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly contractsRepository: ContractsRepository,
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
) {}
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
}
/** Whether a service type bundles customs clearance. */
private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
const serviceType = await this.dataSource
.getRepository(ServiceType)
.findOne({ where: { id: serviceTypeId } });
return serviceType?.includesCustoms ?? false;
}
/** Validate cargo-scope rows against freight type (doc §5.4). */
private assertCargoScopeShape(
freightType: string,
cargoScope: CreateContractDto['cargoScope'],
): void {
if (freightType === 'CONTAINER') {
const sizes = cargoScope.filter((c) =>
['20ft', '40ft'].includes(c.containerSize ?? ''),
);
if (sizes.length === 0) {
throw new BadRequestException(
'CONTAINER contracts require at least one container size (20ft/40ft) in scope',
);
}
} else {
const bulk = cargoScope.filter((c) => c.cargoTypeId);
if (bulk.length !== 1) {
throw new BadRequestException(
'BULK contracts require exactly one cargo-type scope row',
);
}
if (cargoScope.some((c) => c.containerSize)) {
throw new BadRequestException('BULK contracts must not set a container size');
}
}
}
/** Validate route count against contract kind (doc §5.3). */
private assertRouteShape(
contractKind: string,
routes: CreateContractDto['routes'],
): void {
if (contractKind === 'ONE_TIME' && routes.length !== 1) {
throw new BadRequestException('ONE_TIME contracts require exactly one route');
}
if (routes.length < 1) {
throw new BadRequestException('A contract requires at least one route');
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
files: Express.Multer.File[],
userId?: string,
): Promise<{ contract: Contract; warnings: string[] }> {
const warnings: string[] = [];
const isGovernment = dto.isGovernment === true;
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException(
'governmentInstitution is required for government contracts',
);
}
companyId = dto.companyId ?? null;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
'companyId is required or must be resolvable from auth token',
);
}
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
if (company.status !== CompanyStatus.Active) {
throw new ForbiddenException(
"Your company is awaiting approval — you can't create contracts yet.",
);
}
companyId = company.id;
}
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
const reference = dto.reference || (await this.generateReference());
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
dto.tradeDirection,
fallbackType,
);
const customerSelfBooking = !dto.companyId && !!userId;
if (customerSelfBooking && companyProfileId) {
await this.companiesService.assertCompanyProfileApprovedForBooking(
companyProfileId,
);
}
}
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const contract = await this.contractsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
contractKind: dto.contractKind,
renewalOfId: dto.renewalOfId ?? null,
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
customsClearingEnabled: includesCustoms,
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
equipmentReturn: dto.equipmentReturn ?? null,
firstMilePickupAddress: dto.firstMilePickupAddress ?? null,
firstMilePickupLat: dto.firstMilePickupLat ?? null,
firstMilePickupLng: dto.firstMilePickupLng ?? null,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
: null,
contractType: dto.contractType ?? null,
status: 'DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**
* Copy a company profile's stored business-license / onboarding documents onto
* a contract by reference (no byte re-upload). Codes are slugged from each
* document name so they group under "Profile documents" on the contract detail
* page. No-op when the contract has no profile or the profile has no documents.
*/
private async attachProfileDocuments(
contractId: string,
companyProfileId: string | null,
): Promise<void> {
if (!companyProfileId) return;
const profile = await this.dataSource
.getRepository(CompanyProfile)
.findOne({ where: { id: companyProfileId } });
const docs = profile?.businessLicenseFiles ?? [];
if (docs.length === 0) return;
const slug = (name: string) =>
name
.toLowerCase()
.replace(/\.[a-z0-9]+$/, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '') || 'profile_document';
try {
await this.filesService.attachExistingFiles(
contractId,
'contracts',
docs.map((d, i) => ({
code: `${slug(d.name)}_${i + 1}`,
name: d.name,
url: d.url,
size: d.size,
mimeType: d.mimeType,
})),
);
} catch {
// Non-fatal — the contract is still valid without the carried documents.
}
}
private async persistRoutes(
contractId: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const repo = this.dataSource.getRepository(ContractRoute);
await repo.save(
routes.map((r, i) =>
repo.create({
contractId,
originYardId: r.originYardId,
destinationYardId: r.destinationYardId,
km: r.km ?? null,
sortOrder: r.sortOrder ?? i,
}),
),
);
}
private async persistCargoScope(
contractId: string,
cargoScope: CreateContractDto['cargoScope'],
contractKind: string,
): Promise<void> {
const repo = this.dataSource.getRepository(ContractCargoScope);
// A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
// ONE_TIME allows a single booking, so any cap on it is meaningless → null.
const isGeneral = contractKind === 'GENERAL';
await repo.save(
cargoScope.map((c) =>
repo.create({
contractId,
containerSize: c.containerSize ?? null,
cargoTypeId: c.cargoTypeId ?? null,
cargoFreeText: c.cargoFreeText ?? null,
quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
}),
),
);
}
/** Update a DRAFT / CHANGES_REQUESTED contract. */
async update(
id: string,
dto: UpdateContractDto,
files: Express.Multer.File[],
): Promise<{ contract: Contract; warnings: string[] }> {
const existing = await this.findById(id);
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
throw new BadRequestException(
'Only DRAFT or CHANGES_REQUESTED contracts can be updated',
);
}
const warnings: string[] = [];
const freightType = dto.freightType ?? existing.freightType;
const contractKind = dto.contractKind ?? existing.contractKind;
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
const updates: Record<string, unknown> = {
contractKind,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
freightType,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng,
lastMileDeliveryAddress:
dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress,
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat,
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
contractType: dto.contractType ?? existing.contractType,
};
if (dto.estimatedShipmentDate) {
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
}
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
// Customs clearing always mirrors the (possibly changed) service type.
const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId,
);
updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms
? null
: (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);
await this.contractsRepository.update(id, updates);
if (dto.routes) {
await this.dataSource.getRepository(ContractRoute).delete({ contractId: id });
await this.persistRoutes(id, dto.routes);
}
if (dto.cargoScope) {
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'contracts', files);
}
return { contract: await this.findById(id), warnings };
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterContractDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(CONTRACT_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) return { statuses: statusList };
if (filter.status && allowed.has(filter.status)) return { status: filter.status };
return {};
}
async findAll(
filter: FilterContractDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
): Promise<PaginatedContracts> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
return this.contractsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
companyId: forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
contractKind: filter.contractKind,
serviceTypeId: filter.serviceTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** Aggregate metrics and status counts for the backoffice contract list. */
async getListSummary(filter: FilterContractDto): Promise<ContractListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractKind: filter.contractKind,
serviceTypeId: filter.serviceTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
};
const [statusCounts, metrics] = await Promise.all([
this.contractsRepository.getStatusCounts(),
this.contractsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
}),
]);
return { metrics, statusCounts };
}
/** Get a single contract by ID with relations and signed file URLs. */
async findById(id: string): Promise<Contract> {
const contract = await this.contractsRepository.findByIdWithRelations(id);
if (!contract) {
throw new NotFoundException(`Contract ${id} not found`);
}
if (contract.files && contract.files.length > 0) {
contract.files = await Promise.all(
contract.files.map(async (file: FileRecord) => {
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl } as FileRecord;
}),
);
}
return contract;
}
async findByReference(reference: string): Promise<Contract> {
const found = await this.contractsRepository.findByReference(reference);
if (!found) {
throw new NotFoundException(`Contract with reference "${reference}" not found`);
}
return this.findById(found.id);
}
/** Upload intake documents for a DRAFT contract. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Contract> {
const contract = await this.findById(id);
if (contract.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT contracts',
);
}
await this.filesService.uploadMany(id, 'contracts', files);
return this.findById(id);
}
async remove(id: string): Promise<void> {
const contract = await this.findById(id);
if (contract.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT contracts can be deleted');
}
await this.contractsRepository.softDelete(id);
}
/** Resolve the company a customer user belongs to, for scoping their contracts. */
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/** Authorize a customer's access to a single contract (hides as NotFound otherwise). */
async assertCustomerCanAccessContract(
userId: string | undefined,
contract: Contract,
): Promise<void> {
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const companyId = await this.resolveCustomerCompanyId(userId);
if (!companyId || contract.companyId !== companyId) {
throw new NotFoundException(`Contract ${contract.id} not found`);
}
}
}

View File

@@ -0,0 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, Max, Min } from 'class-validator';
export class AcceptContractDto {
@ApiProperty({
description:
'How many days the contract stays valid, counted from the accept date. ' +
'The contract is valid from now through now + validityDays.',
minimum: 1,
maximum: 3650,
example: 365,
})
@IsInt()
@Min(1)
@Max(3650)
validityDays!: number;
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ApproveStepDto {
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@IsString()
requiredRole!: string;
}
export class RequestChangesDto {
@ApiProperty({ description: 'Note explaining what the customer must fix' })
@IsString()
@MinLength(1)
note!: string;
}
export class RejectContractDto {
@ApiProperty()
@IsString()
@MinLength(1)
reason!: string;
}
export class RejectStepDto {
@ApiProperty()
@IsString()
@MinLength(1)
reason!: string;
}
export class CancelContractDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
export class ContractListSummaryMetricsDto {
@ApiProperty({ example: 42 })
inQueue!: number;
@ApiProperty({ example: 10 })
onThisPage!: number;
@ApiProperty({ example: 8 })
needsAction!: number;
}
export class ContractListSummaryDto {
@ApiProperty({ type: ContractListSummaryMetricsDto })
metrics!: ContractListSummaryMetricsDto;
@ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } })
statusCounts!: Record<string, number>;
}

View File

@@ -0,0 +1,105 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
/** A requested container line (no per-unit data — GL enters that at booking). */
export class RequestContainerLineDto {
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
@IsString()
containerSize!: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
}
export class RequestBulkLineDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string | null;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
itemCount?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
}
/** Customer's shipment request on a GENERAL customs contract. */
export class CreateBookingRequestDto {
@ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' })
@IsOptional()
@IsUUID()
contractRouteId?: string;
@ApiPropertyOptional({ description: 'Preferred shipment day (informational).' })
@IsOptional()
@IsString()
scheduledDate?: string;
@ApiPropertyOptional({ type: [RequestContainerLineDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RequestContainerLineDto)
containers?: RequestContainerLineDto[];
@ApiPropertyOptional({ type: RequestBulkLineDto })
@IsOptional()
@ValidateNested()
@Type(() => RequestBulkLineDto)
bulk?: RequestBulkLineDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class ReviewBookingRequestDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
/** One physical container under a booking line — entered at booking time. */
export class CreateContainerUnitDto {
@ApiProperty()
@IsString()
containerNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sealNumber?: string;
@ApiProperty({ description: 'VGM in tons', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmTons!: number;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
}
export class CreateBookingContainerLineDto {
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
@IsString()
containerSize!: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
@ApiProperty({ type: [CreateContainerUnitDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateContainerUnitDto)
units!: CreateContainerUnitDto[];
}
export class CreateBulkLineDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoTypeId?: string | null;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
itemCount?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
}
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
export class CreateBookingUnderContractDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.',
})
@IsOptional()
@IsUUID()
contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' })
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerLineDto)
containers?: CreateBookingContainerLineDto[];
@ApiPropertyOptional({ type: [CreateBulkLineDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateBulkLineDto)
bulkLines?: CreateBulkLineDto[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,256 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
IsIn,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
export {
CONTRACT_KINDS,
TRADE_DIRECTIONS,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
EQUIPMENT_RETURNS,
};
/** One cargo-scope row — a container size OR a bulk commodity. NO quantities. */
export class CreateContractCargoScopeDto {
@ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' })
@IsOptional()
@IsString()
@MaxLength(10)
containerSize?: string | null;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' })
@IsOptional()
@IsUUID()
cargoTypeId?: string | null;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
cargoFreeText?: string | null;
@ApiPropertyOptional({
description:
'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.',
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Transform(({ value }) => (value == null || value === '' ? null : Number(value)))
quantityCap?: number | null;
}
/** A contracted lane (origin → destination). Routes carry NO quantity. */
export class CreateContractRouteInputDto {
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
@IsUUID()
originYardId!: string;
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
@IsUUID()
destinationYardId!: string;
@ApiPropertyOptional({
description: 'Road billing distance (km); null for rail-only.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) =>
value === undefined || value === null || value === '' ? undefined : Number(value),
)
km?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) =>
value === undefined || value === null || value === '' ? undefined : Number(value),
)
sortOrder?: number;
}
export class CreateContractDto {
@ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ description: 'Staff only: government contract flag' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@IsOptional()
@IsUUID()
companyId?: string;
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
@IsIn([...CONTRACT_KINDS])
contractKind!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === '' || value == null ? undefined : value))
renewalOfId?: string;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ enum: FREIGHT_TYPES })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
customsClearingEnabled?: boolean;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
@IsOptional()
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLat?: number;
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
firstMilePickupLng?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLat?: number;
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
lastMileDeliveryLng?: number;
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_hazardous' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiPropertyOptional({
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
example: '2026-07-15T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
@IsOptional()
@IsString()
@MaxLength(20)
contractType?: string;
@ApiProperty({
type: [CreateContractCargoScopeDto],
description:
'Cargo scope rows. CONTAINER: ≥1 size row. BULK: exactly one cargo-type row.',
})
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateContractCargoScopeDto)
cargoScope!: CreateContractCargoScopeDto[];
@ApiProperty({
type: [CreateContractRouteInputDto],
description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.',
})
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateContractRouteInputDto)
routes!: CreateContractRouteInputDto[];
}

View File

@@ -0,0 +1,93 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export class FilterContractDto {
@ApiPropertyOptional({ enum: CONTRACT_STATUSES })
@IsOptional()
@IsIn([...CONTRACT_STATUSES])
status?: string;
@ApiPropertyOptional({
description:
'Filter by statuses: comma-separated or repeated query params. Overrides status when set.',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
statuses?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
companyProfileId?: string;
@ApiPropertyOptional({ enum: CONTRACT_KINDS })
@IsOptional()
@IsIn([...CONTRACT_KINDS])
contractKind?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
serviceTypeId?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Filter contracts created on/before this date (ISO)' })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
page?: number;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'contractValidUntil'])
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
import { CUSTOMS_RISK_LEVELS } from '../entities/clearance-milestone.entity';
import { INCIDENT_TYPES } from '../entities/clearance-incident.entity';
export class AssignRiskDto {
@ApiProperty({ enum: CUSTOMS_RISK_LEVELS })
@IsIn(CUSTOMS_RISK_LEVELS as unknown as string[])
riskLevel!: (typeof CUSTOMS_RISK_LEVELS)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AdviseDutyDto {
@ApiProperty({ description: 'Duty & tax amount advised to the customer' })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@MinLength(1)
currency!: string;
@ApiPropertyOptional({ description: 'Customs declaration serial number' })
@IsOptional()
@IsString()
declarationSerial?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AssignStationDto {
@ApiProperty({ description: 'Origin-station yard the shipment is routed to' })
@IsUUID()
stationYardId!: string;
@ApiPropertyOptional({ description: 'GL staff user bound to this shipment' })
@IsOptional()
@IsUUID()
staffId?: string;
}
export class ReportIncidentDto {
@ApiProperty({ enum: INCIDENT_TYPES })
@IsIn(INCIDENT_TYPES as unknown as string[])
incidentType!: (typeof INCIDENT_TYPES)[number];
@ApiProperty({ description: 'Mandatory free-text narrative of the anomaly' })
@IsString()
@MinLength(1)
description!: string;
}
export class CompleteMilestoneDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class RenewContractDto {
@ApiPropertyOptional({ description: 'Reference of the prior contract being renewed.' })
@IsOptional()
@IsString()
previousContractReference?: string;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class ReviewClearanceDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()
@MinLength(1)
fileKey!: string;
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
@IsIn(['APPROVED', 'QUERIED'])
status!: 'APPROVED' | 'QUERIED';
@ApiPropertyOptional({ description: 'Required when querying a document' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class SignContractDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
@ApiPropertyOptional({
description:
'PNG signature image as base64 (with or without data URL prefix). ' +
'Optional: when omitted, the signer\'s reusable saved signature from their ' +
'profile is used instead.',
})
@IsOptional()
@IsString()
@MinLength(20)
signatureImageBase64?: string;
@ApiProperty()
@IsString()
@MinLength(1)
signerDisplayName!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
consentText?: string;
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContractDto } from './create-contract.dto';
/** Partial contract update — allowed only in DRAFT / CHANGES_REQUESTED. */
export class UpdateContractDto extends PartialType(CreateContractDto) {}

View File

@@ -0,0 +1,61 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import type { Freight } from '@edr/types';
import { Contract } from './contract.entity';
/**
* A customer's request to ship under a GENERAL customs (Path B) contract. The
* customer cannot book directly; they submit the date + quantities here, Global
* Logistics reviews the queue, then creates the booking on their behalf — after
* which per-booking customs clearance begins. ONE_TIME contracts do not use this
* (they keep contract-level clearance). See plan: per-booking clearance.
*/
@Entity({ schema: 'freight', name: 'booking_requests' })
@Index(['contractId'])
@Index(['status'])
@Index(['contractId', 'status'])
export class BookingRequest extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 40, default: '' })
reference!: string;
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
contractRouteId?: string | null;
/** Customer's preferred shipment day — informational; GL sets the binding date. */
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 16, default: 'PENDING' })
status!: Freight.BookingRequestStatus;
/** Requested quantities (container lines or one bulk line) — no per-unit data. */
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
requestedLines!: Freight.RequestedShipmentLines;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
/** Set when GL accepts the request and creates the booking. */
@Column({ name: 'created_booking_id', type: 'uuid', nullable: true })
createdBookingId?: string | null;
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
reviewedByStaffId?: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date | null;
@Column({ name: 'review_note', type: 'text', nullable: true })
reviewNote?: string | null;
}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
/**
* Standard anomaly taxonomy for cargo exception reporting at a border/port
* station (GL Import US-07 AC1.2). GL Djibouti logs one of these against a
* shipment with a free-text narrative and photo evidence; a high-priority alert
* is then surfaced to GL Ethiopia.
*/
export const INCIDENT_TYPES = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];
/**
* A cargo exception/damage report raised during loading or handover. Attaches to
* a booking (post-booking phase) and carries one or more photo file references
* for verification. See docs/new-doc.md §14 gap #18, GL Import US-07.
*/
@Entity({ schema: 'freight', name: 'clearance_incidents' })
@Index(['bookingId'])
export class ClearanceIncident extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'incident_type', type: 'varchar', length: 32 })
incidentType!: IncidentType;
@Column({ name: 'description', type: 'text' })
description!: string;
/** MinIO file ids of the uploaded photo evidence (.jpg). */
@Column({ name: 'photo_file_ids', type: 'jsonb', default: () => "'[]'" })
photoFileIds!: string[];
@Column({ name: 'reported_by_user_id', type: 'uuid', nullable: true })
reportedByUserId?: string | null;
@Column({ name: 'reported_at', type: 'timestamptz', default: () => 'NOW()' })
reportedAt!: Date;
}

View File

@@ -0,0 +1,84 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Contract } from './contract.entity';
export const MILESTONE_STATUSES = ['PENDING', 'COMPLETED', 'SKIPPED'] as const;
export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* Structured payload some milestones carry beyond a plain note (doc §11.3):
* - RISK_ASSIGNED → `riskLevel`
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline.
*/
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
}
/**
* A GL clearance milestone (1823 per direction). Pre-booking milestones attach
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
* See §5.12, §5.16, §11.3.
*/
@Entity({ schema: 'freight', name: 'clearance_milestones' })
@Index(['bookingId'])
@Index(['contractId'])
@Index(['ownerRegion', 'status'])
export class ClearanceMilestone extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
@Column({ name: 'contract_id', type: 'uuid', nullable: true })
contractId?: string | null;
@ManyToOne(() => Contract, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract | null;
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
clearanceCycleId?: string | null;
@Column({ name: 'milestone_code', type: 'varchar', length: 64 })
milestoneCode!: string;
@Column({ name: 'milestone_label', type: 'varchar', length: 255 })
milestoneLabel!: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: MilestoneStatus;
@Column({ name: 'owner_region', type: 'varchar', length: 5, nullable: true })
ownerRegion?: MilestoneOwnerRegion | null;
@Column({ name: 'triggered_by_doc', type: 'boolean', default: false })
triggeredByDoc!: boolean;
@Column({ name: 'triggered_at', type: 'timestamptz', nullable: true })
triggeredAt?: Date | null;
@Column({ name: 'triggered_by_user_id', type: 'uuid', nullable: true })
triggeredByUserId?: string | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: MilestoneMetadata | null;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Contract } from './contract.entity';
export const CONTRACT_APPROVAL_STEP_STATUSES = [
'PENDING',
'APPROVED',
'REJECTED',
'SKIPPED',
] as const;
export type ContractApprovalStepStatus =
(typeof CONTRACT_APPROVAL_STEP_STATUSES)[number];
@Entity({ schema: 'freight', name: 'contract_approval_steps' })
@Index(['contractId'])
@Index(['status'])
@Index(['contractId', 'stepOrder'])
export class ContractApprovalStep extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.approvalSteps, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'step_order', type: 'smallint', default: 0 })
stepOrder!: number;
@Column({ name: 'required_role', type: 'varchar', length: 40 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ContractApprovalStepStatus;
@Column({ name: 'acted_by_staff_id', type: 'uuid', nullable: true })
actedByStaffId?: string | null;
@Column({ name: 'acted_at', type: 'timestamptz', nullable: true })
actedAt?: Date | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -0,0 +1,44 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { Contract } from './contract.entity';
/**
* What cargo sizes/commodities are in scope for a contract — NO quantities.
* Container: one row per enabled size (20ft/40ft). Bulk: one row with a cargo
* type. See §5.4.
*/
@Entity({ schema: 'freight', name: 'contract_cargo_scope' })
@Index(['contractId'])
export class ContractCargoScope extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.cargoScope, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
/** '20ft' | '40ft'; null for bulk. */
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
containerSize?: string | null;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType, { nullable: true })
@JoinColumn({ name: 'cargo_type_id' })
cargoType?: CargoType | null;
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
cargoFreeText?: string | null;
/**
* GENERAL contracts: total cargo quantity allowed across ALL shipments on this
* scope line over the validity window. Container → number of containers of
* this size; bulk → tons (or items for per-item commodities). Bookings draw
* down against it until the cap is reached. NULL = uncapped (always NULL for
* ONE_TIME, which allows a single booking).
*/
@Column({ name: 'quantity_cap', type: 'numeric', precision: 12, scale: 2, nullable: true })
quantityCap?: number | null;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Contract } from './contract.entity';
/**
* One pre-booking clearance round on a contract (Path B). GENERAL contracts run
* many cycles; ONE_TIME uses cycle_number = 1. The booking GL creates is linked
* back via bookingId. See §5.16.
*/
@Entity({ schema: 'freight', name: 'contract_clearance_cycles' })
@Index(['contractId'])
export class ContractClearanceCycle extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.clearanceCycles, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'cycle_number', type: 'int' })
cycleNumber!: number;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'AWAITING_DOCUMENTS' })
status!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'now()' })
startedAt!: Date;
@Column({ name: 'clearance_ready_at', type: 'timestamptz', nullable: true })
clearanceReadyAt?: Date | null;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date | null;
}

View File

@@ -0,0 +1,55 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Contract } from './contract.entity';
export const CONTRACT_DOC_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
export type ContractDocReviewStatus =
(typeof CONTRACT_DOC_REVIEW_STATUSES)[number];
export const CONTRACT_DOC_UPLOADER_ROLES = ['CUSTOMER', 'GL_ET', 'GL_DJ'] as const;
export type ContractDocUploaderRole =
(typeof CONTRACT_DOC_UPLOADER_ROLES)[number];
/**
* Per-document pre-booking clearance review on a contract (Path B). Mirrors
* BookingDocumentReview but keyed on contract_id (+ optional clearance cycle).
* See §5.16.
*/
@Entity({ schema: 'freight', name: 'contract_document_review' })
@Index(['contractId'])
@Index(['status'])
export class ContractDocumentReview extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true })
clearanceCycleId?: string | null;
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
settingCode!: string;
@Column({ name: 'file_key', type: 'varchar', length: 128 })
fileKey!: string;
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
fileRecordId?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ContractDocReviewStatus;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'uploaded_by_role', type: 'varchar', length: 20, default: 'CUSTOMER' })
uploadedByRole!: ContractDocUploaderRole;
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
reviewedByStaffId?: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date | null;
}

View File

@@ -0,0 +1,47 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Contract } from './contract.entity';
/**
* Frozen UNIT rates at contract submit time — one row per rate line. The booking
* computes totals from these × entered quantities. See §5.7.
*/
@Entity({ schema: 'freight', name: 'contract_rate_snapshots' })
@Index(['contractId'])
export class ContractRateSnapshot extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.rateSnapshots, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'rate_id', type: 'uuid', nullable: true })
rateId?: string | null;
@Column({ name: 'rate_code', type: 'varchar', length: 64 })
rateCode!: string;
@Column({ name: 'description', type: 'varchar', length: 255, nullable: true })
description?: string | null;
@Column({ name: 'unit_price', type: 'numeric', precision: 14, scale: 2 })
unitPrice!: number;
/** per_container | per_ton | per_item | per_km | flat */
@Column({ name: 'unit_of_measure', type: 'varchar', length: 32 })
unitOfMeasure!: string;
@Column({ name: 'currency', type: 'varchar', length: 5 })
currency!: string;
@Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true })
containerSize?: string | null;
@Column({ name: 'is_surcharge', type: 'boolean', default: false })
isSurcharge!: boolean;
/** is_hazardous | is_reefer when this is a conditional surcharge. */
@Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true })
conditionalOn?: string | null;
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Contract } from './contract.entity';
export const CONTRACT_REVIEW_NOTE_TYPES = [
'CHANGES_REQUESTED',
'REJECTION',
'STAFF_NOTE',
'CUSTOMER_NOTE',
'AMENDMENT',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'contract_review_notes' })
@Index(['contractId'])
export class ContractReviewNote extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.reviewNotes, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'note_type', type: 'varchar', length: 40 })
noteType!: ContractReviewNoteType;
@Column({ name: 'body', type: 'text' })
body!: string;
@Column({ name: 'author_role', type: 'varchar', length: 20, nullable: true })
authorRole?: string | null;
@Column({ name: 'author_user_id', type: 'uuid', nullable: true })
authorUserId?: string | null;
}

View File

@@ -0,0 +1,40 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Contract } from './contract.entity';
/**
* An allowed origin→destination lane of a contract. Routes carry NO quantity —
* the contract scope just lists which lanes shipments may use. See §5.3.
*/
@Entity({ schema: 'freight', name: 'contract_routes' })
@Index(['contractId'])
export class ContractRoute extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.routes, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
/** Road billing distance; null for rail-only. */
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
km?: number | null;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -0,0 +1,37 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { FileRecord } from '../../files/entities/file.entity';
import { Contract } from './contract.entity';
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] as const;
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
@Entity({ schema: 'freight', name: 'contract_signatures' })
@Index(['contractId'])
export class ContractSignature extends BaseEntity {
@Column({ name: 'contract_id', type: 'uuid' })
contractId!: string;
@ManyToOne(() => Contract, (c) => c.signatures, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
@Column({ name: 'role', type: 'varchar', length: 20 })
role!: ContractSignerRole;
@Column({ name: 'signer_display_name', type: 'varchar', length: 255 })
signerDisplayName!: string;
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
signatureFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;
@Column({ name: 'signed_at', type: 'timestamptz', default: () => 'now()' })
signedAt!: Date;
}

View File

@@ -0,0 +1,257 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { FileRecord } from '../../files/entities/file.entity';
import { ContractRoute } from './contract-route.entity';
import { ContractCargoScope } from './contract-cargo-scope.entity';
import { ContractRateSnapshot } from './contract-rate-snapshot.entity';
import { ContractSignature } from './contract-signature.entity';
import { ContractApprovalStep } from './contract-approval-step.entity';
import { ContractReviewNote } from './contract-review-note.entity';
import { ContractClearanceCycle } from './contract-clearance-cycle.entity';
export const CONTRACT_STATUSES = [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'APPROVED',
'APPROVED_PENDING_SIGNATURE',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'CONTRACT_CLOSED',
'EXPIRED',
'REJECTED',
'CANCELLED',
'RENEWAL_DRAFT',
'RENEWAL_SUBMITTED',
'RENEWAL_PENDING_APPROVAL',
'AMENDMENTS_PROPOSED',
'ARCHIVED',
] as const;
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const;
export type ContractKindValue = (typeof CONTRACT_KINDS)[number];
export const CONTRACT_CLEARANCE_STATUSES = [
'NOT_APPLICABLE',
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking
'SELF_CLEARED', // Path A — Operations approved self-clearance; customer may book
'ACTIVE_SHIPMENT_IN_PROGRESS',
] as const;
export type ContractClearanceStatusValue =
(typeof CONTRACT_CLEARANCE_STATUSES)[number];
/** Statuses where the customer may still edit contract fields. */
export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
'DRAFT',
'CHANGES_REQUESTED',
];
/**
* The legal/commercial agreement. Defines what cargo sizes/commodities, routes,
* and flags are in scope plus the frozen unit rates — but NO quantities. Spawns
* shipment {@link Booking} rows via bookings.contract_id. See docs/new-doc.md §5.2.
*/
@Entity({ schema: 'freight', name: 'contracts' })
@Index(['companyId'])
@Index(['status'])
@Index(['contractKind'])
export class Contract extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })
companyProfile?: CompanyProfile | null;
@Column({ name: 'is_government', type: 'boolean', default: false })
isGovernment!: boolean;
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
governmentInstitution?: string | null;
@Column({ name: 'contract_kind', type: 'varchar', length: 20 })
contractKind!: ContractKindValue;
@Column({ name: 'renewal_of_id', type: 'uuid', nullable: true })
renewalOfId?: string | null;
@ManyToOne(() => Contract, { nullable: true })
@JoinColumn({ name: 'renewal_of_id' })
renewalOf?: Contract | null;
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
freightType!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@ManyToOne(() => ServiceType)
@JoinColumn({ name: 'service_type_id' })
serviceType?: ServiceType;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20, nullable: true })
equipmentReturn?: string | null;
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLat?: number | null;
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
firstMilePickupLng?: number | null;
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLat?: number | null;
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;
@Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean;
@Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true })
estimatedShipmentDate?: Date | null;
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
contractValidityDays?: number | null;
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
contractValidFrom?: Date | null;
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
contractValidUntil?: Date | null;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@Column({ name: 'clearance_cycle_number', type: 'int', default: 0 })
clearanceCycleNumber!: number;
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null;
@Column({ name: 'pricing_display_mode', type: 'varchar', length: 20, default: 'UNIT_RATES', nullable: true })
pricingDisplayMode?: string | null;
@Column({ name: 'contract_type', type: 'varchar', length: 20, nullable: true })
contractType?: string | null;
@Column({ name: 'contract_template_key', type: 'varchar', length: 128, nullable: true })
contractTemplateKey?: string | null;
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
@Column({ name: 'version_number', type: 'int', default: 1 })
versionNumber!: number;
@Column({ name: 'financial_terms', type: 'jsonb', nullable: true })
financialTerms?: Record<string, unknown> | null;
@Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true })
approvedByStaffId?: string | null;
@Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true })
approvedByStaffAt?: Date | null;
@Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true })
signedByDirectorId?: string | null;
@Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true })
signedByDirectorAt?: Date | null;
@Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true })
signedByCeoId?: string | null;
@Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true })
signedByCeoAt?: Date | null;
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt?: Date | null;
@OneToMany(() => ContractRoute, (r) => r.contract)
routes?: ContractRoute[];
@OneToMany(() => ContractCargoScope, (c) => c.contract)
cargoScope?: ContractCargoScope[];
@OneToMany(() => ContractRateSnapshot, (s) => s.contract)
rateSnapshots?: ContractRateSnapshot[];
@OneToMany(() => ContractSignature, (s) => s.contract)
signatures?: ContractSignature[];
@OneToMany(() => ContractApprovalStep, (s) => s.contract)
approvalSteps?: ContractApprovalStep[];
@OneToMany(() => ContractReviewNote, (n) => n.contract)
reviewNotes?: ContractReviewNote[];
@OneToMany(() => ContractClearanceCycle, (c) => c.contract)
clearanceCycles?: ContractClearanceCycle[];
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})
files?: FileRecord[];
}

View File

@@ -0,0 +1,161 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
* uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
* marks the milestone done so the timeline advances without a separate click.
*/
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
t1_transport_document: 'T1_CLOSED', // import — GL ET
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
};
/**
* Operational Global Logistics actions that hang off a shipment booking after GL
* creates it: station routing, damage/incident reporting, and the phased GL
* document uploads (Release Order, Delivery Order, T1, etc.) that advance
* doc-triggered milestones. See docs/new-doc.md §11§13, gap matrix #14/#16/#18.
*/
@Injectable()
export class GlOperationsService {
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
) {}
private get bookings() {
return this.dataSource.getRepository(Booking);
}
private get incidents() {
return this.dataSource.getRepository(ClearanceIncident);
}
private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
/**
* Route a shipment to an origin station and (optionally) bind a GL staff user
* to it (GL US-02). Setting both moves the shipment to that station's queue.
*/
async assignStation(
bookingId: string,
input: { stationYardId: string; staffId?: string },
): Promise<Booking> {
const booking = await this.getBooking(bookingId);
booking.glStationYardId = input.stationYardId;
if (input.staffId) {
booking.glAssignedStaffId = input.staffId;
booking.glAssignedAt = new Date();
}
return this.bookings.save(booking);
}
/** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
async reportIncident(
bookingId: string,
input: {
incidentType: IncidentType;
description: string;
files: Express.Multer.File[];
},
userId?: string,
): Promise<ClearanceIncident> {
await this.getBooking(bookingId);
if (!input.description?.trim()) {
throw new BadRequestException('A description is required for an incident report.');
}
const photoFileIds: string[] = [];
for (const file of input.files ?? []) {
const record = await this.filesService.upload({
resourceId: bookingId,
resource: 'bookings',
code: 'incident_photo',
file,
});
photoFileIds.push(record.id);
}
const incident = this.incidents.create({
bookingId,
incidentType: input.incidentType,
description: input.description.trim(),
photoFileIds,
reportedByUserId: userId ?? null,
reportedAt: new Date(),
});
return this.incidents.save(incident);
}
async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
return this.incidents.find({
where: { bookingId },
order: { reportedAt: 'DESC' },
});
}
/**
* Customer uploads the duty/tax payment slip after GL advised the amount. The
* slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
*/
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
): Promise<{ milestoneCompleted: boolean }> {
await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt',
file,
});
await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
return { milestoneCompleted: true };
}
/**
* GL uploads a post-booking operational document (DO, RO, T1, import release,
* interchange…). The file attaches to the booking; if the code maps to a
* doc-triggered milestone, that milestone auto-completes.
*/
async uploadDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: number; completedMilestones: string[] }> {
await this.getBooking(bookingId);
if (!files?.length) throw new BadRequestException('No documents uploaded');
const completedMilestones: string[] = [];
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: file.fieldname,
file,
});
const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
if (milestoneCode) {
await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
completedMilestones.push(milestoneCode);
}
}
return { uploaded: files.length, completedMilestones };
}
}

View File

@@ -1,5 +1,13 @@
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { Response } from "express";
import { FilesService } from "./files.service";
@@ -10,19 +18,39 @@ export class FilesController {
constructor(private readonly filesService: FilesService) {}
@Get(":fileId")
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
// those requests can't carry the Bearer token the axios client injects, so a
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
@Public()
@ApiOperation({
summary: "Download a file by ID",
summary: "Stream a file by ID",
description:
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
"No resource context (e.g. booking ID) required.",
"No resource context (e.g. booking ID) required. Serves inline by default so " +
"the browser can preview it; pass ?download=1 to force a download.",
})
@ApiQuery({
name: "download",
required: false,
description: "Set to 1/true to force a download instead of inline preview.",
})
async download(
@Param("fileId", ParseUUIDPipe) fileId: string,
@Query("download") download: string | undefined,
@Res() res: Response,
) {
const { stream, record } = await this.filesService.streamById(fileId);
const forceDownload = download === "1" || download === "true";
const disposition = forceDownload ? "attachment" : "inline";
res.setHeader("Content-Type", record.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`);
res.setHeader(
"Content-Disposition",
`${disposition}; filename="${record.name}"`,
);
// Allow the browser to cache the streamed bytes briefly for smoother
// in-page previews (re-opening the viewer shouldn't re-hit MinIO).
res.setHeader("Cache-Control", "private, max-age=300");
stream.pipe(res);
}
}

View File

@@ -12,6 +12,20 @@ export interface CreateFileInput {
file: Express.Multer.File;
}
/**
* Make a filename safe to use as a MinIO object-key segment: collapse runs of
* spaces/unsafe characters to a single underscore while keeping the dot before
* the extension. Prevents percent-encoding mismatches between the stored URL
* and the actual object key.
*/
function sanitizeObjectName(name: string): string {
return name
.normalize("NFKD")
.replace(/[^\w.\-]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_+|_+$/g, "");
}
@Injectable()
export class FilesService {
constructor(
@@ -21,7 +35,12 @@ export class FilesService {
async upload(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code, file } = input;
const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`;
// Keep the object key URL-safe so it survives the round-trip through the
// stored URL (spaces/unicode in the original name would otherwise be
// percent-encoded in the URL and no longer match the MinIO key). The
// human-readable name is preserved separately on the record below.
const safeName = sanitizeObjectName(file.originalname);
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
return this.filesRepository.create({

View File

@@ -34,6 +34,9 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ type: 'boolean', default: false })
isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -1,9 +1,9 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
@@ -36,7 +36,7 @@ export class FirstMileService {
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
/**
@@ -44,30 +44,19 @@ export class FirstMileService {
* paid before any first-mile work proceeds. Throws if the reference is
* unknown or the booking has not reached PAID status.
*/
async acceptBooking(bookingId: string): Promise<FirstMile | null> {
async acceptBooking(bookingId: string): Promise<FirstMile> {
const booking = await this.bookingsRepository.findById(bookingId, {
relations: { serviceType: true },
});
if (!booking) {
return null;
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if (booking.paymentStatus !== 'PAID') {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
});
return this.acceptEligibleBooking(booking);
}
async acceptBookingByReference(bookingReference: string): Promise<FirstMile | null> {
async acceptBookingByReference(bookingReference: string): Promise<FirstMile> {
const [booking] = await this.bookingsRepository.findAll({
where: { reference: bookingReference },
relations: { serviceType: true },
@@ -75,15 +64,39 @@ export class FirstMileService {
});
if (!booking) {
return null;
throw new NotFoundException(`Booking ${bookingReference} not found`);
}
return this.acceptEligibleBooking(booking);
}
/**
* Shared accept path: validates payment + first-mile eligibility, rejects an
* already-assigned booking, then creates the first-mile record. Throws a
* meaningful HTTP error instead of returning null so the client can surface
* why an accept was refused.
*/
private async acceptEligibleBooking(booking: {
id: string;
reference?: string;
paymentStatus?: string | null;
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): Promise<FirstMile> {
const label = booking.reference ?? booking.id;
if (booking.paymentStatus !== 'PAID') {
return null;
throw new BadRequestException(`Booking ${label} is not paid`);
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
throw new BadRequestException(`Booking ${label} does not require a first mile`);
}
const existing = await this.findByBookingId(booking.id);
if (existing) {
throw new ConflictException(`Booking ${label} already has a first-mile assignment`);
}
return this.create({
@@ -174,10 +187,17 @@ export class FirstMileService {
}
private bookingRequestsFirstMile(booking: {
tradeDirection?: string | null;
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
return Boolean(booking.firstMilePickupAddress?.trim() || booking.serviceType?.includesFirstMile);
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
return Boolean(
booking.tradeDirection === 'EXPORT' ||
booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile,
);
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
@@ -205,6 +225,16 @@ export class FirstMileService {
return updated;
}
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
return updated;
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
@@ -221,16 +251,19 @@ export class FirstMileService {
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.firstMilePickupAddress,
destinationYard: booking?.originYard?.label,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -0,0 +1,188 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity';
import {
EMPTY_CONTAINER_RETURN_STATUSES,
type EmptyContainerReturnStatus,
} from '../entities/empty-container-return.entity';
import {
IMPORT_CUSTOMS_RISK_LEVELS,
type ImportCustomsDocumentType,
type ImportCustomsRiskLevel,
} from '../entities/import-customs-finalization.entity';
export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [
'IM4',
'IM5',
'T1_CLOSURE_PROOF',
'TRANSIT_PERMIT_SCREENSHOT',
'CUSTOMER_PAYMENT_SLIP',
'IMPORT_RELEASE_PERMIT',
] as const;
export class CreateDjiboutiIncidentDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
bookingId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
containerNumber?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
cargoId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
facility?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
station?: string;
@ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES })
@IsIn(DJIBOUTI_INCIDENT_TYPES)
incidentType!: DjiboutiIncidentType;
@ApiProperty()
@IsString()
description!: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
photos?: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
reportedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
reportedAt?: string;
}
export class UploadImportCustomsDocumentDto {
@ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES })
@IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES)
documentType!: ImportCustomsDocumentType;
@ApiProperty()
@IsString()
fileId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class RecordDeclarationDto {
@ApiProperty()
@IsString()
declarationSerialNumber!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class AssignCustomsRiskDto {
@ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS })
@IsIn(IMPORT_CUSTOMS_RISK_LEVELS)
risk!: ImportCustomsRiskLevel;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class ImportOperationActionDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
export class CreateEmptyContainerReturnDto {
@ApiProperty()
@IsString()
containerNumber!: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
bookingId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
returnDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
facility?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
yard?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
zone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
condition?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
handoverNote?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
@ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES })
@IsIn(EMPTY_CONTAINER_RETURN_STATUSES)
status!: EmptyContainerReturnStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
wagonAllocationReference?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
handoverNote?: string;
}

View File

@@ -0,0 +1,50 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const DJIBOUTI_INCIDENT_TYPES = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
'QUANTITY_MISMATCH',
'WEIGHT_MISMATCH',
'OTHER',
] as const;
export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number];
@Entity({ schema: 'freight', name: 'djibouti_import_incidents' })
@Index(['bookingId'])
@Index(['containerNumber'])
@Index(['incidentType'])
export class DjiboutiIncident extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true })
containerNumber?: string | null;
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
cargoId?: string | null;
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
facility?: string | null;
@Column({ name: 'station', type: 'varchar', length: 120, nullable: true })
station?: string | null;
@Column({ name: 'incident_type', type: 'varchar', length: 40 })
incidentType!: DjiboutiIncidentType;
@Column({ name: 'description', type: 'text' })
description!: string;
@Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" })
photos!: string[];
@Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true })
reportedBy?: string | null;
@Column({ name: 'reported_at', type: 'timestamptz' })
reportedAt!: Date;
}

View File

@@ -0,0 +1,56 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const EMPTY_CONTAINER_RETURN_STATUSES = [
'RETURNED',
'ASSIGNED_STORAGE',
'DOCUMENTATION_CLEARED',
'WAGON_ALLOCATED',
'TRANSPORTED_TO_DJIBOUTI',
'HANDOVER_ISSUED',
'COMPLETED',
] as const;
export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number];
@Entity({ schema: 'freight', name: 'empty_container_returns' })
@Index(['containerNumber'])
@Index(['bookingId'])
@Index(['status'])
export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'container_number', type: 'varchar', length: 80 })
containerNumber!: string;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
customerId?: string | null;
@Column({ name: 'return_date', type: 'timestamptz' })
returnDate!: Date;
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
facility?: string | null;
@Column({ name: 'yard', type: 'varchar', length: 120, nullable: true })
yard?: string | null;
@Column({ name: 'zone', type: 'varchar', length: 120, nullable: true })
zone?: string | null;
@Column({ name: 'condition', type: 'text', nullable: true })
condition?: string | null;
@Column({ name: 'handover_note', type: 'text', nullable: true })
handoverNote?: string | null;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' })
status!: EmptyContainerReturnStatus;
@Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true })
wagonAllocationReference?: string | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
}

View File

@@ -0,0 +1,48 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const;
export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number];
export type ImportCustomsDocumentType =
| 'IM4'
| 'IM5'
| 'T1_CLOSURE_PROOF'
| 'TRANSIT_PERMIT_SCREENSHOT'
| 'CUSTOMER_PAYMENT_SLIP'
| 'IMPORT_RELEASE_PERMIT';
@Entity({ schema: 'freight', name: 'import_customs_finalizations' })
@Index(['bookingId'], { unique: true })
@Index(['customsRisk'])
export class ImportCustomsFinalization extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
documents!: Partial<Record<ImportCustomsDocumentType, string>>;
@Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true })
declarationSerialNumber?: string | null;
@Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true })
dutiesTaxesNotifiedAt?: Date | null;
@Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true })
dutiesTaxesPaidAt?: Date | null;
@Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true })
customsRisk?: ImportCustomsRiskLevel | null;
@Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true })
importReleasePermittedAt?: Date | null;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date | null;
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,110 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
AssignCustomsRiskDto,
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
RecordDeclarationDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import { ImportOperationsService } from './import-operations.service';
@ApiTags('import-operations')
@ApiBearerAuth()
@Controller('import-operations')
export class ImportOperationsController {
constructor(private readonly service: ImportOperationsService) {}
@Get('djibouti-incidents')
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
listIncidents(@Query('bookingId') bookingId?: string) {
return this.service.listIncidents(bookingId);
}
@Post('djibouti-incidents')
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
return this.service.createIncident(dto);
}
@Get('customs/:bookingId')
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.service.getCustoms(bookingId);
}
@Post('customs/:bookingId/documents')
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
uploadCustomsDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: UploadImportCustomsDocumentDto,
) {
return this.service.uploadCustomsDocument(bookingId, dto);
}
@Post('customs/:bookingId/declaration')
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
recordDeclaration(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: RecordDeclarationDto,
) {
return this.service.recordDeclaration(bookingId, dto);
}
@Post('customs/:bookingId/notify-duties-taxes')
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
notifyDutiesTaxes(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.notifyDutiesTaxes(bookingId, dto);
}
@Post('customs/:bookingId/duties-taxes-paid')
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
markDutiesTaxesPaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markDutiesTaxesPaid(bookingId, dto);
}
@Post('customs/:bookingId/risk')
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
return this.service.assignRisk(bookingId, dto);
}
@Post('customs/:bookingId/release-permitted')
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
markReleasePermitted(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ImportOperationActionDto,
) {
return this.service.markReleasePermitted(bookingId, dto);
}
@Get('empty-container-returns')
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
listEmptyReturns() {
return this.service.listEmptyReturns();
}
@Post('empty-container-returns')
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
return this.service.createEmptyReturn(dto);
}
@Post('empty-container-returns/:id/status')
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
updateEmptyReturnStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEmptyContainerReturnStatusDto,
) {
return this.service.updateEmptyReturnStatus(id, dto);
}
}

View File

@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
import { ImportOperationsController } from './import-operations.controller';
import { ImportOperationsService } from './import-operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([
DjiboutiIncident,
ImportCustomsFinalization,
EmptyContainerReturn,
]),
],
controllers: [ImportOperationsController],
providers: [ImportOperationsService],
exports: [ImportOperationsService],
})
export class ImportOperationsModule {}

View File

@@ -0,0 +1,211 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
CreateDjiboutiIncidentDto,
CreateEmptyContainerReturnDto,
ImportOperationActionDto,
RecordDeclarationDto,
AssignCustomsRiskDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import {
DjiboutiIncident,
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
type ImportCustomsDocumentType,
} from './entities/import-customs-finalization.entity';
const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
];
@Injectable()
export class ImportOperationsService {
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@InjectRepository(ImportCustomsFinalization)
private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
) {}
listIncidents(bookingId?: string) {
return this.incidents.find({
where: bookingId ? { bookingId } : {},
order: { reportedAt: 'DESC', createdAt: 'DESC' } as never,
});
}
async createIncident(dto: CreateDjiboutiIncidentDto) {
const photos = dto.photos ?? [];
if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) {
throw new BadRequestException('Photos are required for damage-related Djibouti incidents');
}
const incident = await this.incidents.save(
this.incidents.create({
bookingId: dto.bookingId,
containerNumber: dto.containerNumber ?? null,
cargoId: dto.cargoId ?? null,
facility: dto.facility ?? null,
station: dto.station ?? null,
incidentType: dto.incidentType,
description: dto.description,
photos,
reportedBy: dto.reportedBy ?? null,
reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(),
}),
);
console.log(
`[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`,
);
console.log(
`[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`,
);
return incident;
}
async getCustoms(bookingId: string) {
return this.getOrCreateCustoms(bookingId);
}
async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) {
const row = await this.getOrCreateCustoms(bookingId);
const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId };
await this.customs.update(row.id, {
documents,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
declarationSerialNumber: dto.declarationSerialNumber,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`);
return this.getCustoms(bookingId);
}
async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid');
await this.customs.update(row.id, {
dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
return this.getCustoms(bookingId);
}
async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
customsRisk: dto.risk,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertReleaseReady(row);
await this.customs.update(row.id, {
importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(),
completedAt: row.completedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
listEmptyReturns() {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
return this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null,
}),
);
}
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
await this.emptyReturns.update(id, {
status: dto.status,
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;
return this.customs.save(this.customs.create({ bookingId, documents: {} }));
}
private assertDocument(
row: ImportCustomsFinalization,
type: ImportCustomsDocumentType,
message: string,
) {
if (!row.documents?.[type]) {
throw new BadRequestException(message);
}
}
private assertReleaseReady(row: ImportCustomsFinalization) {
this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release');
this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted');
if (!row.declarationSerialNumber?.trim()) {
throw new BadRequestException('Declaration serial number is required before import release');
}
if (!row.customsRisk) {
throw new BadRequestException('Customs risk must be assigned before import release');
}
if (!row.dutiesTaxesPaidAt) {
throw new BadRequestException('Duties and taxes must be paid before import release');
}
}
}

View File

@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
)
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
c.seal_number AS "sealNumber",
NULL AS "cargoId",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CONTAINER' AS "itemType",
'CONTAINER'::varchar AS "itemType",
bc.id AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL::uuid AS "bookingCargoId",
bc.container_number AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
'CARGO' AS "itemType",
NULL AS "bookingContainerId",
'CARGO'::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
cg.id AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
cg.id AS "cargoId",
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
UNION ALL
SELECT a.booking_id AS "bookingId",
a.reference AS "bookingReference",
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
NULL AS "bookingContainerId",
NULL AS "bookingCargoId",
NULL AS "containerNumber",
NULL AS "sealNumber",
NULL AS "cargoId",
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
NULL::uuid AS "bookingContainerId",
NULL::uuid AS "bookingCargoId",
NULL::varchar AS "containerNumber",
NULL::varchar AS "sealNumber",
NULL::uuid AS "cargoId",
a.booking_cargo_type AS "cargoType",
a.cargo_free_text AS "cargoDescription",
a.cargo_total_weight_vgm AS "weight",

View File

@@ -34,6 +34,9 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ type: 'boolean', default: false })
isPostPaymentCompleted!: boolean;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;

View File

@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { DriversService } from '../drivers/drivers.service';
import { NotificationsService } from '../notifications/notifications.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
@@ -32,12 +32,11 @@ export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
constructor(
private readonly lastMileRepository: LastMileRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly vehiclesService: VehiclesService,
private readonly driversService: DriversService,
private readonly notificationsService: NotificationsService,
private readonly smsClient: SmsClientService,
) {}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
@@ -185,16 +184,19 @@ export class LastMileService {
};
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
await this.notificationsService.notifyDriverVehicleAssignment({
driverPhone: driver.phoneNumber,
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
bookingReference: booking?.reference ?? record.bookingId,
pickupAddress: booking?.destinationYard?.label,
destinationYard: booking?.lastMileDeliveryAddress,
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
const message =
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
void this.smsClient.sendSms({
to: driver.phoneNumber,
message,
});
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
} catch (err) {
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
}

View File

@@ -8,10 +8,13 @@ import {
} from '../entities/locomotive.entity';
export class CreateLocomotiveDto {
@ApiProperty({ example: 'LOCO-001' })
// Optional on input — the service auto-generates a sequential LOCO-NNN code
// when none is supplied.
@ApiPropertyOptional({ example: 'LOCO-001' })
@IsOptional()
@IsString()
@MaxLength(32)
code!: string;
code?: string;
@ApiPropertyOptional()
@IsOptional()
@@ -32,11 +35,13 @@ export class CreateLocomotiveDto {
@IsUUID()
currentYardId?: string;
@ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value))
// Defaults to 2500 tons when omitted (see service).
@ApiPropertyOptional({ example: 2500, default: 2500 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
maxPullWeightTons!: number;
maxPullWeightTons?: number;
@ApiProperty({ example: 760 })
@Transform(({ value }) => Number(value))

View File

@@ -29,20 +29,40 @@ export class LocomotivesService {
});
}
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
/** Default max pull weight (tons) applied when the caller omits it. */
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;
/**
* Generate the next sequential locomotive code (LOCO-001, LOCO-002, …) by
* scanning the highest existing LOCO-NNN number. Used when the caller does not
* supply a code.
*/
private async generateCode(): Promise<string> {
const all = await this.locomotivesRepository.findAll({});
let max = 0;
for (const loco of all) {
const match = /^LOCO-(\d+)$/.exec(loco.code ?? '');
if (match) max = Math.max(max, Number(match[1]));
}
return `LOCO-${String(max + 1).padStart(3, '0')}`;
}
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
const code = dto.code?.trim() || (await this.generateCode());
const [existing] = await this.locomotivesRepository.findAll({ where: { code } });
if (existing) {
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
throw new ConflictException(`Locomotive code ${code} already exists`);
}
return this.locomotivesRepository.create({
code: dto.code,
code,
name: dto.name?.trim() || null,
locomotiveType: dto.locomotiveType as LocomotiveType,
status: dto.status as LocomotiveStatus,
currentYardId: dto.currentYardId ?? null,
maxPullWeightTons: dto.maxPullWeightTons,
maxPullWeightTons:
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,

View File

@@ -65,7 +65,14 @@ export class MinioService {
}
const url = new URL(trimmed);
const parts = url.pathname.split("/").filter(Boolean);
// `url.pathname` percent-encodes the object key (e.g. a space becomes
// "%20"), but MinIO stores the key with its literal characters. Decode each
// segment so the recovered key matches what was uploaded — otherwise a file
// whose name had spaces/unicode 404s with "specified key does not exist".
const parts = url.pathname
.split("/")
.filter(Boolean)
.map((segment) => decodeURIComponent(segment));
if (parts[0] === this.bucket) {
parts.shift();
}

View File

@@ -8,6 +8,14 @@ export class OverviewBookingKpisDto {
@ApiProperty() submittedToday!: number;
}
export class OverviewContractKpisDto {
@ApiProperty() totalActive!: number;
@ApiProperty() needsAction!: number;
@ApiProperty() inApproval!: number;
@ApiProperty() inClearance!: number;
@ApiProperty() createdToday!: number;
}
export class OverviewOperationsKpisDto {
@ApiProperty() trainsActive!: number;
@ApiProperty() wagonsAvailable!: number;
@@ -36,6 +44,9 @@ export class OverviewKpisDto {
@ApiProperty({ type: OverviewBookingKpisDto })
bookings!: OverviewBookingKpisDto;
@ApiProperty({ type: OverviewContractKpisDto })
contracts!: OverviewContractKpisDto;
@ApiProperty({ type: OverviewOperationsKpisDto })
operations!: OverviewOperationsKpisDto;
@@ -81,6 +92,18 @@ export class OverviewRecentBookingDto {
@ApiProperty() createdAt!: string;
}
export class OverviewRecentContractDto {
@ApiProperty() id!: string;
@ApiProperty() reference!: string;
@ApiProperty() customerLabel!: string;
@ApiProperty() status!: string;
@ApiProperty() contractKind!: string;
@ApiProperty() freightType!: string;
@ApiProperty({ nullable: true }) paymentCurrency!: string | null;
@ApiProperty({ nullable: true }) validUntil!: string | null;
@ApiProperty() createdAt!: string;
}
export class OverviewResponseDto {
@ApiProperty({ type: OverviewKpisDto })
kpis!: OverviewKpisDto;

View File

@@ -3,11 +3,13 @@ import { ApiProperty } from '@nestjs/swagger';
import {
OverviewBillingKpisDto,
OverviewBookingKpisDto,
OverviewContractKpisDto,
OverviewCustomerKpisDto,
OverviewOperationsKpisDto,
OverviewPaymentTrendPointDto,
OverviewPipelineCountDto,
OverviewRecentBookingDto,
OverviewRecentContractDto,
OverviewStaffKpisDto,
OverviewStatusCountDto,
OverviewTrendPointDto,
@@ -56,6 +58,32 @@ export class OverviewBookingsTabDto {
generatedAt!: string;
}
export class OverviewContractsTabDto {
@ApiProperty({ type: OverviewContractKpisDto })
kpis!: OverviewContractKpisDto;
@ApiProperty({ type: [OverviewTrendPointDto] })
contractTrend!: OverviewTrendPointDto[];
@ApiProperty({ type: [OverviewStatusCountDto] })
contractsByStatus!: OverviewStatusCountDto[];
@ApiProperty({ type: [OverviewPipelineCountDto] })
contractsByPipeline!: OverviewPipelineCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
contractsByKind!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewLabelCountDto] })
contractsByFreightType!: OverviewLabelCountDto[];
@ApiProperty({ type: [OverviewRecentContractDto] })
recentContracts!: OverviewRecentContractDto[];
@ApiProperty()
generatedAt!: string;
}
export class OverviewBillingTabDto {
@ApiProperty({ type: OverviewBillingKpisDto })
kpis!: OverviewBillingKpisDto;

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